diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/fixtures/c/directives.c | 7 | ||||
| -rw-r--r-- | tests/fixtures/c/inline_ref.c | 5 | ||||
| -rw-r--r-- | tests/fixtures/c/multi_chunk.c | 11 | ||||
| -rw-r--r-- | tests/fixtures/c/no_comments.c | 3 | ||||
| -rw-r--r-- | tests/fixtures/c/ref_string.c | 6 | ||||
| -rw-r--r-- | tests/fixtures/c/rst_exec.c | 8 | ||||
| -rw-r--r-- | tests/fixtures/c/shared_namespace.c | 11 | ||||
| -rw-r--r-- | tests/fixtures/c/simple.c | 5 | ||||
| -rw-r--r-- | tests/fixtures/c/src_repo.c | 5 | ||||
| -rw-r--r-- | tests/fixtures/c/toc.c | 5 | ||||
| -rw-r--r-- | tests/fixtures/lua/simple.lua | 5 | ||||
| -rw-r--r-- | tests/fixtures/lua/single_line.lua | 2 | ||||
| -rw-r--r-- | tests/fixtures/md/readme.md | 2 | ||||
| -rw-r--r-- | tests/fixtures/md/simple.md | 3 | ||||
| -rw-r--r-- | tests/fixtures/py/simple.py | 6 | ||||
| -rw-r--r-- | tests/fixtures/py/single_line.py | 2 | ||||
| -rw-r--r-- | tests/helpers/common.bash | 263 | ||||
| -rw-r--r-- | tests/helpers/profiler.bash | 53 | ||||
| -rw-r--r-- | tests/integration/cli.bats | 215 | ||||
| -rw-r--r-- | tests/integration/pipeline.bats | 842 | ||||
| -rw-r--r-- | tests/run_tests.sh | 57 | ||||
| -rw-r--r-- | tests/unit/directives.bats | 235 | ||||
| -rw-r--r-- | tests/unit/extraction.bats | 171 | ||||
| -rw-r--r-- | tests/unit/template_vars.bats | 160 |
24 files changed, 2082 insertions, 0 deletions
diff --git a/tests/fixtures/c/directives.c b/tests/fixtures/c/directives.c new file mode 100644 index 0000000..4becc88 --- /dev/null +++ b/tests/fixtures/c/directives.c @@ -0,0 +1,7 @@ +/* md +@name test/directives +@file generated/source.c +@ref test/directives-ref +Directive test body content. +*/ +void directives_demo() {} diff --git a/tests/fixtures/c/inline_ref.c b/tests/fixtures/c/inline_ref.c new file mode 100644 index 0000000..72ef395 --- /dev/null +++ b/tests/fixtures/c/inline_ref.c @@ -0,0 +1,5 @@ +/* md +@name test/inline-ref +See @lua/Coroutines for details on cooperative multitasking. +*/ +void inline_ref_func() {} diff --git a/tests/fixtures/c/multi_chunk.c b/tests/fixtures/c/multi_chunk.c new file mode 100644 index 0000000..57fd463 --- /dev/null +++ b/tests/fixtures/c/multi_chunk.c @@ -0,0 +1,11 @@ +/* md +@name test/alpha +First chunk content. +*/ +void foo() {} + +/* md +@name test/beta +Second chunk content. +*/ +void bar() {} diff --git a/tests/fixtures/c/no_comments.c b/tests/fixtures/c/no_comments.c new file mode 100644 index 0000000..a667cef --- /dev/null +++ b/tests/fixtures/c/no_comments.c @@ -0,0 +1,3 @@ +/* This is a regular comment, not a doc comment */ +// Another non-doc comment +int noop() { return 42; } diff --git a/tests/fixtures/c/ref_string.c b/tests/fixtures/c/ref_string.c new file mode 100644 index 0000000..711ac20 --- /dev/null +++ b/tests/fixtures/c/ref_string.c @@ -0,0 +1,6 @@ +/* md +@name test/ref-string +@ref lua/Coroutines +Return type: {{ ref_string }} +*/ +void ref_string_func() {} diff --git a/tests/fixtures/c/rst_exec.c b/tests/fixtures/c/rst_exec.c new file mode 100644 index 0000000..3aabf9f --- /dev/null +++ b/tests/fixtures/c/rst_exec.c @@ -0,0 +1,8 @@ +/* rst +@name test/rst-exec +RST Heading +=========== + +This chunk should be rendered by the rst renderer. +*/ +int noop() { return 0; } diff --git a/tests/fixtures/c/shared_namespace.c b/tests/fixtures/c/shared_namespace.c new file mode 100644 index 0000000..25f064a --- /dev/null +++ b/tests/fixtures/c/shared_namespace.c @@ -0,0 +1,11 @@ +/* md +@name test/shared +Part one of the shared page. +*/ +void part_one() {} + +/* md +@name test/shared +Part two of the shared page. +*/ +void part_two() {} diff --git a/tests/fixtures/c/simple.c b/tests/fixtures/c/simple.c new file mode 100644 index 0000000..2595929 --- /dev/null +++ b/tests/fixtures/c/simple.c @@ -0,0 +1,5 @@ +/* md +@name test/simple +Hello from a C comment. +*/ +int main() { return 0; } diff --git a/tests/fixtures/c/src_repo.c b/tests/fixtures/c/src_repo.c new file mode 100644 index 0000000..fb0d8b6 --- /dev/null +++ b/tests/fixtures/c/src_repo.c @@ -0,0 +1,5 @@ +/* md +@name test/src-repo +Generated from {{ src_repo }}/src.c +*/ +void src_repo_func() {} diff --git a/tests/fixtures/c/toc.c b/tests/fixtures/c/toc.c new file mode 100644 index 0000000..bb0782b --- /dev/null +++ b/tests/fixtures/c/toc.c @@ -0,0 +1,5 @@ +/* md +@name test/toc-chunk +Navigation: {{ toc }} +*/ +void toc_func() {} diff --git a/tests/fixtures/lua/simple.lua b/tests/fixtures/lua/simple.lua new file mode 100644 index 0000000..de527a9 --- /dev/null +++ b/tests/fixtures/lua/simple.lua @@ -0,0 +1,5 @@ +--[[ md +@name test/lua-simple +Hello from a Lua comment. +]] +local x = 1 diff --git a/tests/fixtures/lua/single_line.lua b/tests/fixtures/lua/single_line.lua new file mode 100644 index 0000000..f541825 --- /dev/null +++ b/tests/fixtures/lua/single_line.lua @@ -0,0 +1,2 @@ +--[[ md This comment opens and closes on one line — no chunk. ]] +local x = 1 diff --git a/tests/fixtures/md/readme.md b/tests/fixtures/md/readme.md new file mode 100644 index 0000000..5833cab --- /dev/null +++ b/tests/fixtures/md/readme.md @@ -0,0 +1,2 @@ +# Project Overview +Hello from the readme. diff --git a/tests/fixtures/md/simple.md b/tests/fixtures/md/simple.md new file mode 100644 index 0000000..494ade7 --- /dev/null +++ b/tests/fixtures/md/simple.md @@ -0,0 +1,3 @@ +@name test/md-simple +# Markdown Page +Hello from a markdown file. diff --git a/tests/fixtures/py/simple.py b/tests/fixtures/py/simple.py new file mode 100644 index 0000000..b8e2ce9 --- /dev/null +++ b/tests/fixtures/py/simple.py @@ -0,0 +1,6 @@ +'''md +@name test/py-simple +Hello from a Python doc comment. +''' +def noop(): + pass diff --git a/tests/fixtures/py/single_line.py b/tests/fixtures/py/single_line.py new file mode 100644 index 0000000..baafa10 --- /dev/null +++ b/tests/fixtures/py/single_line.py @@ -0,0 +1,2 @@ +'''This comment opens and closes on one line — no chunk.''' +x = 1 diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash new file mode 100644 index 0000000..779cc96 --- /dev/null +++ b/tests/helpers/common.bash @@ -0,0 +1,263 @@ +# tests/helpers/common.bash +# Sourced by every bats test file via `load`. +# Provides: workspace isolation, PATH stubs, assertion helpers. + +# ── workspace setup/teardown ──────────────────────────────────────────────── + +# Call in setup(): creates an isolated temp directory and cds into it. +# A stub for md2html is pre-installed in a fake bin/ on PATH. +setup_workspace() { + TEST_TMPDIR="$(mktemp -d)" + # trap 'rm -rf $TEST_TMPDIR' EXIT + # Fake bin injected before the real PATH so stubs take priority + STUB_BIN="$TEST_TMPDIR/bin" + mkdir -p "$STUB_BIN" + install_stub_md2html "$STUB_BIN" + ORIG_DIR="$(pwd)" + export PATH="$STUB_BIN:$PATH" + # Work inside the temp dir so .trblcache is isolated + pushd "$TEST_TMPDIR" >/dev/null +} + +# Call in teardown(): removes the temp directory. +teardown_workspace() { + popd >/dev/null 2>&1 || true + rm -rf "$TEST_TMPDIR" +} + +# ── stubs ─────────────────────────────────────────────────────────────────── + +# md2html stub: echoes a predictable HTML marker for easy assertion. +# Accepts any flags; always succeeds. +install_stub_md2html() { + local bin_dir="$1" + cat > "$bin_dir/md2html" <<'EOF' +#!/usr/bin/env bash +# Stub: find the last non-flag argument as the input file, or read stdin. +# Flags (args starting with -) from the md alias are ignored. +input="" +for arg in "$@"; do + [[ "$arg" == -* ]] || input="$arg" +done +if [[ -n "$input" ]]; then + content="$(cat "$input")" +else + content="$(cat)" +fi +echo "<p class=\"stub-md\">$content</p>" +EOF + chmod +x "$bin_dir/md2html" +} + + +# ── AWK program builders ───────────────────────────────────────────────────── +# These mirror the comment_form patterns from trbldoc.sh so extraction +# logic can be unit-tested without sourcing the whole script. + +CACHE_DIR=".trblcache" + +# Returns the AWK program string for C-style /* */ comments. +awk_prog_c() { + local cache="$1" + cat <<EOF +BEGIN{} +/\*\// { + if(in_comment) {print "EOF"} + in_comment=0 +} +in_comment {print} +/\/\* .+/ && !/\*\// { + print "chunkfile=\$(mktemp -p ${cache}/chunks)" + print "cat > \$chunkfile << \"EOF\"\\n@file " name ":" NR ; + if(\$2) {\$1=""; print "@exec " \$0} + in_comment=1; +} +EOF +} + +# Returns the AWK program string for Lua +awk_prog_lua() { + local cache="$1" + cat <<EOF +BEGIN{} +/\]\]/ { + if(in_comment) {print "EOF"} + in_comment=0 +} +in_comment {print} +/--\[\[ .+/ && !/\]\]$/ { + print "chunkfile=\$(mktemp -p ${cache}/chunks)" + print "cat > \$chunkfile << \"EOF\"\\n@file " name ":" NR ; + if(\$2) {\$1=""; print "@exec " \$0} + in_comment=1; +} +EOF +} + +# Returns the AWK program string for Python. +# Handles '''renderer format (renderer glued directly to ''', no space required). +awk_prog_py() { + local cache="$1" + sed "s|__PY_CACHE__|${cache}|g" << 'PYEOF' +BEGIN{} +/'''/ { + if(in_comment) {print "EOF"} + in_comment=0 +} +in_comment {print} +/'''.+/ && !/'''$/ { + print "chunkfile=$(mktemp -p __PY_CACHE__/chunks)" + print "cat > $chunkfile << \"EOF\"\n@file " name ":" NR ; + renderer = $0 + sub(/'''/, "", renderer) + sub(/^[[:space:]]+/, "", renderer) + sub(/[[:space:]]+$/, "", renderer) + if (renderer != "") {print "@exec " renderer} + in_comment=1; +} +PYEOF +} + +# Returns the AWK program string for .md files +awk_prog_md() { + local cache="$1" + cat <<EOF +BEGIN{ + print "chunkfile=\$(mktemp -p ${cache}/chunks)" + print "cat > \$chunkfile << \"EOF\"\\n@file " name ":1\\n@exec md" ; +} +{print \$0} +END{print "EOF"} +EOF +} + +# ── extraction helper ──────────────────────────────────────────────────────── + +# run_extraction FILE AWK_PROG CACHE_DIR +# Runs AWK against FILE, evals the output shell script, and populates CACHE_DIR/chunks. +run_extraction() { + local file="$1" + local awk_prog="$2" + local cache="$3" + mkdir -p "$cache/chunks" + local chunk_script + chunk_script=$(awk -v name="$file" "$awk_prog" < "$file") + eval "$chunk_script" +} + +# chunk_count CACHE_DIR +# Prints the number of chunk files produced. +chunk_count() { + find "$1/chunks" -type f 2>/dev/null | wc -l | tr -d ' ' +} + +# chunk_contains CACHE_DIR PATTERN +# Succeeds if any chunk file contains a line matching PATTERN (grep -q). +chunk_contains() { + grep -rl "$2" "$1/chunks" >/dev/null 2>&1 +} + +# ── assertion helpers ──────────────────────────────────────────────────────── + +# assert_file_exists PATH +assert_file_exists() { + if [[ ! -f "$1" ]]; then + echo "ASSERTION FAILED: expected file to exist: $1" >&2 + return 1 + fi +} + +# assert_dir_exists PATH +assert_dir_exists() { + if [[ ! -d "$1" ]]; then + echo "ASSERTION FAILED: expected directory to exist: $1" >&2 + return 1 + fi +} + +# assert_contains FILE PATTERN +assert_contains() { + if ! grep -q "$2" "$1" 2>/dev/null; then + echo "ASSERTION FAILED: '$2' not found in $1" >&2 + echo "--- file contents ---" >&2 + cat "$1" >&2 + return 1 + fi +} + +# assert_not_contains FILE PATTERN +assert_not_contains() { + if grep -q "$2" "$1" 2>/dev/null; then + echo "ASSERTION FAILED: '$2' unexpectedly found in $1" >&2 + return 1 + fi +} + +# assert_equal ACTUAL EXPECTED +assert_equal() { + if [[ "$1" != "$2" ]]; then + echo "ASSERTION FAILED: expected '$2', got '$1'" >&2 + return 1 + fi +} + +# ── HTML validation helpers ────────────────────────────────────────────────── + +# assert_valid_html FILE +# Validates FILE as a complete HTML5 document using tidy. +# Mustache template markers ({{...}}, {{{...}}}) are substituted with +# structurally valid HTML before linting to avoid false positives from +# template syntax that tidy cannot parse. +# Skips automatically if tidy is not installed. +# tidy exit codes: 0 = clean, 1 = warnings only, 2 = errors. +# This helper fails only on exit code 2 (actual errors). +assert_valid_html() { + local file="$1" + if ! command -v tidy >/dev/null 2>&1; then + skip "tidy not installed (apt install tidy)" + fi + local tmp + tmp=$(mktemp --suffix=.html) + # Replace {{ var }} template markers with valid HTML equivalents so tidy + # sees a structurally correct document. + sed \ + -e 's/{{[^}]*}}/placeholder/g' \ + "$file" > "$tmp" + local tidy_out rc + tidy_out=$(tidy -errors -quiet --show-warnings no -utf8 "$tmp" 2>&1) + rc=$? + rm -f "$tmp" + if [ "$rc" -ge 2 ]; then + echo "HTML VALIDATION FAILED: $file" >&2 + echo "$tidy_out" >&2 + return 1 + fi +} + +# assert_valid_html_fragment FILE +# Wraps FILE in a minimal HTML5 document shell, then validates with tidy. +# Use for HTML fragment files (e.g. rendered chunk index.html output) that +# are not complete documents on their own. +# Skips automatically if tidy is not installed. +assert_valid_html_fragment() { + local file="$1" + if ! command -v tidy >/dev/null 2>&1; then + skip "tidy not installed (apt install tidy)" + fi + local tmp + tmp=$(mktemp --suffix=.html) + { + echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>fragment</title></head><body>' + cat "$file" + echo '</body></html>' + } > "$tmp" + local tidy_out rc + tidy_out=$(tidy -errors -quiet --show-warnings no -utf8 "$tmp" 2>&1) + rc=$? + rm -f "$tmp" + if [ "$rc" -ge 2 ]; then + echo "HTML VALIDATION FAILED: $file" >&2 + echo "$tidy_out" >&2 + return 1 + fi +} diff --git a/tests/helpers/profiler.bash b/tests/helpers/profiler.bash new file mode 100644 index 0000000..c3d5207 --- /dev/null +++ b/tests/helpers/profiler.bash @@ -0,0 +1,53 @@ +# Profiler implementation +profile() { + # Set default options + local file=profiler.log + + # Open a file descriptor for writing to $file and save it in $tracefd + exec {tracefd}>"$file" + # Send trace output to $tracefd + export BASH_XTRACEFD="$tracefd" + # Print microsecond time in trace output + export PS4='+ $EPOCHREALTIME ' + # Enable tracing, run script, and disable tracing + set -x + bash -x -- "$@" + set +x + # Un-redirect the trace output. This also closes the file descriptor. + unset BASH_XTRACEFD + export -n BASH_XTRACEFD PS4 + + # Remove "source" line from output and change last line to only include the + # timestamp with no name. + sed -i -e 1d -e '$s/\(+\+ [0-9\.]\+\) .*$/\1/' "$file" +} + +analyze() { + # Set defaults + local file=profiler.log + local -a sortcmd=(cat) tablecmd=(cat) + + # Declare variables + local timestamp nestlvl cmd next_timestamp next_nestlvl next_cmd duration + # Open file as a file descriptor so we can re-use the same stream + exec {fd}<"$file"; + # Read first line + read -r nestlvl timestamp cmd <&"$fd" + # Process each line + while read -r next_nestlvl next_timestamp next_cmd + do + duration="$(echo "scale=6; $next_timestamp" - "$timestamp" | bc)" + # Prepend leading zero + if [ "${duration:0:1}" = . ] + then + duration="0$duration" + fi + echo "$duration" "$nestlvl" "$cmd" + + timestamp="$next_timestamp" + nestlvl="$next_nestlvl" + cmd="$next_cmd" + done <&"$fd" | "${sortcmd[@]}" | "${tablecmd[@]}" + # Close file descriptor + exec {fd}<&- +} diff --git a/tests/integration/cli.bats b/tests/integration/cli.bats new file mode 100644 index 0000000..5ffc55c --- /dev/null +++ b/tests/integration/cli.bats @@ -0,0 +1,215 @@ +#!/usr/bin/env bats +# tests/integration/cli.bats +# Tests for trbldoc.sh command-line option parsing. +# +# Each test runs the full script (with md2html stubs) and asserts that +# the correct sources are scanned and outputs produced, based on the options +# passed. + +load "../helpers/common.bash" +load "../helpers/profiler.bash" + +FIXTURES="$BATS_TEST_DIRNAME/../fixtures" +_REPO_ROOT="${REPO_ROOT:-$(cd "$BATS_TEST_DIRNAME/../.." && pwd)}" +TRBLDOC="$_REPO_ROOT/trbldoc.sh" + +setup() { + setup_workspace + CACHE=".trblcache" + NS="$CACHE/namespace" +} + +teardown() { + teardown_workspace +} + +# ── helpers ────────────────────────────────────────────────────────────────── + +run_pipeline() { + bash "$TRBLDOC" "$@" # 2>/dev/null + #profile "$TRBLDOC" "$@" 2>/dev/null + #analyze >> profile.txt +} + +stage_fixture() { + local src="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + cp -r "$src" "$dest" +} + +# ── error cases ────────────────────────────────────────────────────────────── + +@test "cli: no arguments exits non-zero with an error message" { + run bash "$TRBLDOC" 2>&1 + [ "$status" -ne 0 ] + [[ "$output" == *"trbldoc:"* ]] +} + +@test "cli: unknown option exits non-zero" { + run bash "$TRBLDOC" -z 2>&1 + [ "$status" -ne 0 ] +} + +@test "cli: option missing its required argument exits non-zero" { + run bash "$TRBLDOC" -s 2>&1 + [ "$status" -ne 0 ] +} + +# ── -s (source folder) ─────────────────────────────────────────────────────── + +@test "cli: -s flag specifies the source folder to scan" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_dir_exists "$NS/test/simple" +} + +@test "cli: multiple -s flags scan all specified folders" { + stage_fixture "$FIXTURES/c/simple.c" "src1/simple.c" + stage_fixture "$FIXTURES/c/multi_chunk.c" "src2/multi_chunk.c" + run_pipeline -s src1 -s src2 + assert_dir_exists "$NS/test/simple" + assert_dir_exists "$NS/test/alpha" +} + +# ── -o (output directory) ──────────────────────────────────────────────────── + +@test "cli: -o flag creates the specified output directory" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src -o my_output + assert_dir_exists "my_output" + assert_file_exists "my_output/test/simple/index.html" +} + +@test "cli: without -o the default output directory .trblcache/built is created" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_dir_exists "$CACHE/built" +} + +# ── -f (custom file extension) ─────────────────────────────────────────────── + +@test "cli: -f flag registers a custom file extension for scanning" { + mkdir -p src + # Create a .myext file with C-style doc comments. + # The -f option takes ext;start_regex;end_regex (AWK patterns without slashes). + cat > src/custom.myext <<'SRCEOF' +/* md +@name test/custom-ext +Hello from a custom extension. +*/ +int noop() {} +SRCEOF + run_pipeline -s src -f 'myext;\/\*;\*\/' + assert_dir_exists "$NS/test/custom-ext" +} + +@test "cli: -f registered extension produces correct chunk content" { + mkdir -p src + cat > src/custom.myext <<'SRCEOF' +/* md +@name test/custom-content +Content from custom ext. +*/ +int noop() {} +SRCEOF + run_pipeline -s src -f 'myext;\/\*;\*\/' + assert_contains "$NS/test/custom-content/index.html" "Content from custom ext" +} + +# ── TRBLDOC_MD (markdown renderer override) ────────────────────────────────── + +@test "cli: TRBLDOC_MD overrides the markdown renderer for .md files" { + mkdir -p src + cat > src/doc.md <<'EOF' +@name test/md-override +# Heading +Some content. +EOF + cat > custom_md.sh <<'EOF' +#!/usr/bin/env bash +# Stub: emit a recognisable marker instead of real HTML. +cat "$@" > /dev/null +echo "<p class=\"custom-md-renderer\">custom renderer was here</p>" +EOF + chmod +x custom_md.sh + TRBLDOC_MD="./custom_md.sh" run_pipeline -s src + assert_contains "$NS/test/md-override/index.html" "custom-md-renderer" +} + +@test "cli: TRBLDOC_MD override is not used when unset (default md2html stub runs)" { + stage_fixture "$FIXTURES/md/simple.md" "src/simple.md" + run_pipeline -s src + # The default stub wraps content in class=stub-md; confirm it ran. + assert_contains "$NS/test/md-simple/index.html" "stub-md" +} + +# ── built-in site assembly ─────────────────────────────────────────────────── + +@test "cli: built output is assembled by default without external zod" { + mkdir -p src + cat > src/simple.c <<'EOF' +/* md +@name test/simple +Hello from a C comment. +*/ +int main() { return 0; } +EOF + run_pipeline -s src + assert_file_exists "$CACHE/built/test/simple/index.html" + assert_contains "$CACHE/built/test/simple/index.html" "<!DOCTYPE html>" + assert_contains "$CACHE/built/test/simple/index.html" "Hello from a C comment" + assert_not_contains "$CACHE/built/test/simple/index.html" "{{{yield}}}" +} + +@test "cli: default main.layout includes stylesheet and excludes highlightjs mermaid and mathjax" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/main.layout" '<link href="/stylesheets/style.css" rel="stylesheet">' + assert_not_contains "$NS/main.layout" "highlight.min.js" + assert_not_contains "$NS/main.layout" "highlightAll()" + assert_not_contains "$NS/main.layout" "mermaid.min.js" + assert_not_contains "$NS/main.layout" "mermaid.initialize" + assert_not_contains "$NS/main.layout" "mathjax" +} + +@test "cli: -l uses the provided main.layout template" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + cat > alt.layout <<'EOF' +<!DOCTYPE html> +<html lang="en"> +<head><meta charset="utf-8"><title>{{ title }}</title></head> +<body> +<header>ALT {{ breadcrumb }}</header> +<nav><ul>{{ nav }}</ul></nav> +<main>{{ yield }}</main> +</body> +</html> +EOF + run_pipeline -s src -l alt.layout + assert_contains "$NS/main.layout" "ALT {{ breadcrumb }}" + assert_not_contains "$NS/main.layout" "Generated by trbldoc" +} + +@test "cli: -l fails when layout template file does not exist" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run bash "$TRBLDOC" -s src -l does-not-exist.layout 2>&1 + [ "$status" -ne 0 ] + [[ "$output" == *"layout template not found"* ]] +} + +@test "cli: files without a registered extension are not scanned" { + mkdir -p src + # .xyz has no registered comment form; no output namespace should appear. + cat > src/ignored.xyz <<'SRCEOF' +/* md +@name test/should-not-exist +This should not be processed. +*/ +SRCEOF + run_pipeline -s src + if [[ -d "$NS/test/should-not-exist" ]]; then + echo "FAILED: .xyz file was unexpectedly processed" >&2 + return 1 + fi +} diff --git a/tests/integration/pipeline.bats b/tests/integration/pipeline.bats new file mode 100644 index 0000000..2f43304 --- /dev/null +++ b/tests/integration/pipeline.bats @@ -0,0 +1,842 @@ +#!/usr/bin/env bats +# tests/integration/pipeline.bats +# End-to-end tests for trbldoc.sh. +# +# External markdown rendering is stubbed via helpers/common.bash. +# Tests inspect both .trblcache/namespace intermediate output and the assembled +# .trblcache/built pages produced by trbldoc itself. +# +# Tests marked `skip` document known bugs from todo.md. Remove `skip` after +# the corresponding bug is fixed. + +load "../helpers/common.bash" + +FIXTURES="$BATS_TEST_DIRNAME/../fixtures" +# REPO_ROOT is exported by run_tests.sh; fall back to relative path when bats +# is invoked directly on this file. +_REPO_ROOT="${REPO_ROOT:-$(cd "$BATS_TEST_DIRNAME/../.." && pwd)}" +TRBLDOC="$_REPO_ROOT/trbldoc.sh" + +setup() { + setup_workspace + CACHE=".trblcache" + NS="$CACHE/namespace" +} + +teardown() { + teardown_workspace +} + +# ── helpers ───────────────────────────────────────────────────────────────── + +# Copy a fixture file into a temporary source tree and run the pipeline. +# Usage: run_pipeline SRC_DIR [additional source dirs...] +run_pipeline() { + bash "$TRBLDOC" "$@" +} + +# Copy a fixture into a local source dir for a clean run. +stage_fixture() { + local src="$1" # path to fixture file or dir + local dest="$2" # destination relative path in tmp workspace + mkdir -p "$(dirname "$dest")" + cp -r "$src" "$dest" +} + +install_busybox_tool_wrapper() { + local tool="$1" + local busybox_bin="$2" + cat > "$STUB_BIN/$tool" <<EOF +#!/bin/sh +if [ -n "\${TRBLDOC_TEST_TOOL_LOG:-}" ]; then + printf '%s\n' "$tool" >> "\$TRBLDOC_TEST_TOOL_LOG" +fi +exec "$busybox_bin" $tool "\$@" +EOF + chmod +x "$STUB_BIN/$tool" +} + +# ── cache structure ────────────────────────────────────────────────────────── + +@test "pipeline: .trblcache directory is created on run" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" +run_pipeline -s src + assert_dir_exists ".trblcache" +} + +@test "pipeline: namespace subdirectory is created on run" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_dir_exists ".trblcache/namespace" +} + +@test "pipeline: chunks subdirectory is created on run" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_dir_exists ".trblcache/chunks" +} + +@test "pipeline: global.meta is created" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_file_exists "$NS/global.meta" +} + +@test "pipeline: main.layout is created" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_file_exists "$NS/main.layout" +} + +@test "pipeline: main.layout passes HTML validation" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_valid_html "$NS/main.layout" +} + +@test "pipeline: main.layout has well-formed HTML structure" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + local layout="$NS/main.layout" + # <html> must appear before <head> + local html_line head_line body_line footer_line close_body_line + html_line=$(grep -n '<html' "$layout" | head -1 | cut -d: -f1) + head_line=$(grep -n '<head' "$layout" | head -1 | cut -d: -f1) + body_line=$(grep -n '<body' "$layout" | head -1 | cut -d: -f1) + footer_line=$(grep -n '<footer' "$layout" | head -1 | cut -d: -f1) + close_body_line=$(grep -n '</body>' "$layout" | head -1 | cut -d: -f1) + # <html> before <head> + [ "$html_line" -lt "$head_line" ] + # exactly one <head> + local head_count + head_count=$(grep -c '<head>' "$layout") + assert_equal "$head_count" "1" + # <footer> inside <body>: footer before </body> + [ "$footer_line" -lt "$close_body_line" ] +} + +@test "pipeline: nav.partial is created" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_file_exists "$NS/nav.partial" +} + +# ── namespace output ───────────────────────────────────────────────────────── + +@test "pipeline: named chunk creates namespace output folder" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_dir_exists "$NS/test/simple" +} + +@test "pipeline: namespace output folder contains index.html" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_file_exists "$NS/test/simple/index.html" +} + +@test "pipeline: built page exists for rendered namespace" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_file_exists "$CACHE/built/test/simple/index.html" +} + +@test "pipeline: built page applies layout tokens and contains rendered body" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$CACHE/built/test/simple/index.html" "<!DOCTYPE html>" + assert_contains "$CACHE/built/test/simple/index.html" "<title>test/simple</title>" + assert_contains "$CACHE/built/test/simple/index.html" "Hello from a C comment" + assert_contains "$CACHE/built/test/simple/index.html" "href='/test/simple'" +} + +@test "pipeline: index.html contains rendered chunk body" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/test/simple/index.html" "Hello from a C comment" +} + +@test "pipeline: runs with BusyBox awk and xargs from PATH" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + + if ! command -v busybox >/dev/null 2>&1; then + skip "busybox is not installed" + fi + + local busybox_bin + busybox_bin="$(command -v busybox)" + export TRBLDOC_TEST_TOOL_LOG="$TEST_TMPDIR/busybox-tools.log" + : > "$TRBLDOC_TEST_TOOL_LOG" + + install_busybox_tool_wrapper awk "$busybox_bin" + install_busybox_tool_wrapper xargs "$busybox_bin" + + run bash "$TRBLDOC" -s src 2>&1 + [ "$status" -eq 0 ] + + assert_file_exists "$NS/test/simple/index.html" + assert_contains "$NS/test/simple/index.html" "Hello from a C comment" + + local awk_count xargs_count + awk_count=$(grep -c '^awk$' "$TRBLDOC_TEST_TOOL_LOG" || true) + xargs_count=$(grep -c '^xargs$' "$TRBLDOC_TEST_TOOL_LOG" || true) + [ "$awk_count" -gt 0 ] + [ "$xargs_count" -gt 0 ] +} + +@test "pipeline: rendered namespace index.html passes HTML validation" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_valid_html_fragment "$NS/test/simple/index.html" +} + +@test "pipeline: multi-chunk file creates two separate namespace folders" { + stage_fixture "$FIXTURES/c/multi_chunk.c" "src/multi_chunk.c" + run_pipeline -s src + assert_dir_exists "$NS/test/alpha" + assert_dir_exists "$NS/test/beta" +} + +@test "pipeline: each namespace folder has its own index.html" { + stage_fixture "$FIXTURES/c/multi_chunk.c" "src/multi_chunk.c" + run_pipeline -s src + assert_file_exists "$NS/test/alpha/index.html" + assert_file_exists "$NS/test/beta/index.html" +} + +@test "pipeline: each namespace index.html contains the correct body" { + stage_fixture "$FIXTURES/c/multi_chunk.c" "src/multi_chunk.c" + run_pipeline -s src + assert_contains "$NS/test/alpha/index.html" "First chunk content" + assert_contains "$NS/test/beta/index.html" "Second chunk content" +} + +@test "pipeline: two chunks with same @name both appear in the shared index.html" { + stage_fixture "$FIXTURES/c/shared_namespace.c" "src/shared_namespace.c" + run_pipeline -s src + assert_contains "$NS/test/shared/index.html" "Part one" + assert_contains "$NS/test/shared/index.html" "Part two" +} + +@test "pipeline: @priority orders chunks before namespace/source ordering" { + mkdir -p src + cat > src/priority_high.c <<'EOF' +/* md +@name test/priority-order +@priority 100 +Priority high +*/ +int high() { return 0; } +EOF + cat > src/priority_low.c <<'EOF' +/* md +@name test/priority-order +@priority 1 +Priority low +*/ +int low() { return 0; } +EOF + cat > src/priority_default.c <<'EOF' +/* md +@name test/priority-order +Priority default +*/ +int def() { return 0; } +EOF + run_pipeline -s src + local out="$NS/test/priority-order/index.html" + local line_high line_low line_default first_render second_render + line_high=$(grep -n "Priority high" "$out" | head -1 | cut -d: -f1) + line_low=$(grep -n "Priority low" "$out" | head -1 | cut -d: -f1) + line_default=$(grep -n "Priority default" "$out" | head -1 | cut -d: -f1) + [ "$line_high" -lt "$line_low" ] + [ "$line_low" -lt "$line_default" ] + first_render="$(cat "$out")" + run_pipeline -s src + second_render="$(cat "$out")" + assert_equal "$first_render" "$second_render" +} + +@test "pipeline: Lua file chunk creates correct namespace output" { + stage_fixture "$FIXTURES/lua/simple.lua" "src/simple.lua" + run_pipeline -s src + assert_file_exists "$NS/test/lua-simple/index.html" +} + +@test "pipeline: Python file chunk creates correct namespace output" { + stage_fixture "$FIXTURES/py/simple.py" "src/simple.py" + run_pipeline -s src + assert_file_exists "$NS/test/py-simple/index.html" +} + +@test "pipeline: Python chunk body appears in namespace output" { + stage_fixture "$FIXTURES/py/simple.py" "src/simple.py" + run_pipeline -s src + assert_contains "$NS/test/py-simple/index.html" "Hello from a Python doc comment" +} + +@test "pipeline: Markdown file chunk creates correct namespace output" { + stage_fixture "$FIXTURES/md/simple.md" "src/simple.md" + run_pipeline -s src + assert_file_exists "$NS/test/md-simple/index.html" +} + +@test "pipeline: @exec rst chunk is rendered using TRBLDOC_RST override" { + stage_fixture "$FIXTURES/c/rst_exec.c" "src/rst_exec.c" + cat > custom_rst.sh <<'EOF' +#!/usr/bin/env bash +if [[ $# -gt 0 ]]; then + content="$(cat "$1")" +else + content="$(cat)" +fi +echo "<section class=\"stub-rst\">$content</section>" +EOF + chmod +x custom_rst.sh + TRBLDOC_RST="./custom_rst.sh" run_pipeline -s src + assert_file_exists "$NS/test/rst-exec/index.html" + assert_contains "$NS/test/rst-exec/index.html" "stub-rst" + assert_contains "$NS/test/rst-exec/index.html" "RST Heading" +} + +@test "pipeline: file with no doc comments creates no namespace folders" { + stage_fixture "$FIXTURES/c/no_comments.c" "src/no_comments.c" + run_pipeline -s src + # Only the fixed namespace files (global.meta, nav.partial, etc.) should exist; + # no test/* content namespace folder should be present. + if [[ -d "$NS/test" ]]; then + echo "FAILED: unexpected namespace folder created for file with no doc comments" >&2 + ls "$NS/test" >&2 + return 1 + fi +} + +# ── global.meta ───────────────────────────────────────────────────────────── + +@test "pipeline: global.meta contains entry for the processed namespace" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/global.meta" "test/simple" +} + +# ── nav.partial ────────────────────────────────────────────────────────────── + +@test "pipeline: nav.partial contains link for processed namespace" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/nav.partial" "test/simple" +} + +@test "pipeline: nav.partial does not contain duplicate entries" { + stage_fixture "$FIXTURES/c/shared_namespace.c" "src/shared_namespace.c" + run_pipeline -s src + # Count occurrences of the shared namespace link; must be exactly 1. + local count + count=$(grep -c "test/shared" "$NS/nav.partial" || true) + assert_equal "$count" "1" +} + +@test "pipeline: nav.partial groups related namespaces as a tree" { + mkdir -p doc + cat > doc/doc.md <<'EOF' +# Doc +EOF + cat > doc/index.md <<'EOF' +# Index +EOF + cat > doc/love_loader.md <<'EOF' +# Love Loader +EOF + cat > doc/ui_notes.md <<'EOF' +# UI Notes +EOF + run_pipeline -s doc + assert_contains "$NS/nav.partial" "<li>doc/<ul>" + assert_contains "$NS/nav.partial" "href='/doc/doc.md'>doc.md</a>" + assert_contains "$NS/nav.partial" "href='/doc/index.md'>index.md</a>" + assert_contains "$NS/nav.partial" "href='/doc/love_loader.md'>love_loader.md</a>" + assert_contains "$NS/nav.partial" "href='/doc/ui_notes.md'>ui_notes.md</a>" +} + +@test "pipeline: dotted @name values are allowed" { + mkdir -p src + cat > src/dotted.c <<'EOF' +/* md +@name ui.element.Element +Dotted namespace. +*/ +int dotted() { return 0; } +EOF + run_pipeline -s src + assert_dir_exists "$NS/ui.element.Element" + assert_contains "$NS/nav.partial" "href='/ui.element.Element'" +} + +@test "pipeline: explicit slash-separated @name builds correct nav tree" { + mkdir -p src + cat > src/ui_parent.c <<'EOF' +/* md +@name ui +UI root documentation. +*/ +int ui_root() { return 0; } +EOF + cat > src/ui_element.c <<'EOF' +/* md +@name ui/element/Element +Element details. +*/ +int ui_element() { return 0; } +EOF + run_pipeline -s src + assert_contains "$NS/nav.partial" "href='/ui'>ui</a>/<ul>" + assert_contains "$NS/nav.partial" "href='/ui/element/Element'>Element</a>" + assert_dir_exists "$NS/ui/element/Element" +} + +# ── inline reference resolution ───────────────────────────────────────────── + +@test "pipeline: @lua/<path> tokens are resolved to Markdown links" { + stage_fixture "$FIXTURES/c/inline_ref.c" "src/inline_ref.c" + run_pipeline -s src + # ref_resolvers/lua.sh "Coroutines" returns https://www.lua.org/manual/5.2/manual.html#2.6 + assert_contains "$NS/test/inline-ref/index.html" "lua.org/manual" + assert_not_contains "$NS/test/inline-ref/index.html" "@lua/Coroutines" +} + +@test "pipeline: -R adds a custom resolver prefix" { + mkdir -p src + cat > src/custom_ref.c <<'EOF' +/* md +@name test/custom-ref +Link: @custom/topic +*/ +int noop() {} +EOF + cat > custom_resolver.sh <<'EOF' +#!/usr/bin/env bash +opt="$1" +path="$(cat)" +printf "https://example.test/%s/%s" "$opt" "$path" +EOF + chmod +x custom_resolver.sh + run_pipeline -s src -R 'custom=./custom_resolver.sh;modeA' + assert_contains "$NS/test/custom-ref/index.html" "https://example.test/modeA/topic" + assert_not_contains "$NS/test/custom-ref/index.html" "@custom/topic" +} + +@test "pipeline: -R overrides an existing resolver prefix" { + mkdir -p src + cat > src/lua_override.c <<'EOF' +/* md +@name test/lua-override +Link: @lua/Coroutines +*/ +int noop() {} +EOF + cat > lua_override.sh <<'EOF' +#!/usr/bin/env bash +path="$(cat)" +printf "https://override.test/%s" "$path" +EOF + chmod +x lua_override.sh + run_pipeline -s src -R 'lua=./lua_override.sh' + assert_contains "$NS/test/lua-override/index.html" "https://override.test/Coroutines" + assert_not_contains "$NS/test/lua-override/index.html" "lua.org/manual" +} + +@test "pipeline: @exec renderer receives TRBLCACHE environment variable (repro)" { + mkdir -p src + cat > src/renderer_cache_env.c <<'EOF' +/* ./require_trblcache.sh +@name test/renderer-cache-env +Renderer env test. +*/ +int noop() { return 0; } +EOF + cat > require_trblcache.sh <<'EOF' +#!/usr/bin/env bash +if [[ -z "${TRBLCACHE:-}" ]]; then + echo "TRBLCACHE is missing in renderer" >&2 + exit 23 +fi +echo "<p>cache=$TRBLCACHE</p>" +EOF + chmod +x require_trblcache.sh + run bash "$TRBLDOC" -s src 2>&1 + [ "$status" -eq 0 ] + assert_contains "$NS/test/renderer-cache-env/index.html" "cache=./.trblcache" + [[ "$output" != *"TRBLCACHE is missing in renderer"* ]] + [[ "$output" != *"renderer failed (exit 23)"* ]] +} + +@test "pipeline: @exec renderer receives FILEPATH environment variable (repro)" { + mkdir -p src + cat > src/renderer_filepath_env.c <<'EOF' +/* ./require_filepath.sh +@name test/renderer-filepath-env +Renderer FILEPATH env test. +*/ +int noop() { return 0; } +EOF + cat > require_filepath.sh <<'EOF' +#!/usr/bin/env bash +if [[ -z "${FILEPATH:-}" ]]; then + echo "FILEPATH is missing in renderer" >&2 + exit 24 +fi +if [[ "${FILEPATH}" != "src/renderer_filepath_env.c" ]]; then + echo "FILEPATH is incorrect in renderer: $FILEPATH" >&2 + exit 25 +fi +echo "<p>filepath=$FILEPATH</p>" +EOF + chmod +x require_filepath.sh + run bash "$TRBLDOC" -s src 2>&1 + [ "$status" -eq 0 ] + assert_contains "$NS/test/renderer-filepath-env/index.html" "filepath=src/renderer_filepath_env.c" + [[ "$output" != *"FILEPATH is missing in renderer"* ]] + [[ "$output" != *"FILEPATH is incorrect in renderer"* ]] + [[ "$output" != *"renderer failed (exit 24)"* ]] + [[ "$output" != *"renderer failed (exit 25)"* ]] +} + +@test "pipeline: @exec renderer receives LINENUM environment variable (repro)" { + mkdir -p src + cat > src/renderer_linenum_env.c <<'EOF' +int prelude = 0; + +/* ./require_linenum.sh +@name test/renderer-linenum-env +Renderer LINENUM env test. +*/ +int noop() { return 0; } +EOF + cat > require_linenum.sh <<'EOF' +#!/usr/bin/env bash +if [[ -z "${LINENUM:-}" ]]; then + echo "LINENUM is missing in renderer" >&2 + exit 26 +fi +if [[ "${LINENUM}" != "3" ]]; then + echo "LINENUM is incorrect in renderer: $LINENUM" >&2 + exit 27 +fi +echo "<p>linenum=$LINENUM</p>" +EOF + chmod +x require_linenum.sh + run bash "$TRBLDOC" -s src 2>&1 + [ "$status" -eq 0 ] + assert_contains "$NS/test/renderer-linenum-env/index.html" "linenum=3" + [[ "$output" != *"LINENUM is missing in renderer"* ]] + [[ "$output" != *"LINENUM is incorrect in renderer"* ]] + [[ "$output" != *"renderer failed (exit 26)"* ]] + [[ "$output" != *"renderer failed (exit 27)"* ]] +} +@test "pipeline: inline @exec function receives TRBLCACHE FILEPATH and LINENUM (repro)" { + mkdir -p src + cat > src/renderer_inline_function_env.c <<'EOF' +/* render_inline(){ if [[ -z "${TRBLCACHE:-}" || -z "${FILEPATH:-}" || -z "${LINENUM:-}" ]]; then echo "missing env in inline renderer function" >&2; return 28; fi; printf "<p>cache=%s filepath=%s linenum=%s</p>\n" "$TRBLCACHE" "$FILEPATH" "$LINENUM"; }; render_inline +@name test/renderer-inline-function-env +Renderer inline function env test. +*/ +int noop() { return 0; } +EOF + run bash "$TRBLDOC" -s src 2>&1 + [ "$status" -eq 0 ] + assert_contains "$NS/test/renderer-inline-function-env/index.html" "cache=./.trblcache" + assert_contains "$NS/test/renderer-inline-function-env/index.html" "filepath=src/renderer_inline_function_env.c" + assert_contains "$NS/test/renderer-inline-function-env/index.html" "linenum=1" + [[ "$output" != *"missing env in inline renderer function"* ]] + [[ "$output" != *"renderer failed (exit 28)"* ]] +} + +@test "pipeline: @name tokens resolve to internal refs before external resolvers" { + # Internal refs: @<name> resolves to the first chunk with @ref <name> + mkdir -p src + cat > src/target.c <<'EOF' +/* md +@name api/target +@ref my-target +Target documentation. +*/ +int target() {} +EOF + cat > src/source.c <<'EOF' +/* md +@name api/source +See also: @my-target for details. +*/ +int source() {} +EOF + run_pipeline -s src + # @my-target should resolve to the internal link from global.meta + assert_contains "$NS/api/source/index.html" "href='/api/target#my-target'" + assert_not_contains "$NS/api/source/index.html" "@my-target" +} + +# ── template variable substitution ───────────────────────────────────────── + +@test "pipeline: {{ src_repo }} is substituted with TRBLDOC_SRC_REPO value" { + stage_fixture "$FIXTURES/c/src_repo.c" "src/src_repo.c" + TRBLDOC_SRC_REPO="https://example.com/repo" run_pipeline -s src + assert_contains "$NS/test/src-repo/index.html" "https://example.com/repo" + assert_not_contains "$NS/test/src-repo/index.html" '{{ src_repo }}' +} + +@test "pipeline: {{ ref_string }} is substituted with the resolved reference link" { + stage_fixture "$FIXTURES/c/ref_string.c" "src/ref_string.c" + run_pipeline -s src + # @ref lua/Coroutines is resolved via ref_resolvers/lua.sh; the output + # contains the full ref name as link text and the resolved URL. + assert_contains "$NS/test/ref-string/index.html" "lua/Coroutines" + assert_contains "$NS/test/ref-string/index.html" "lua.org/manual" + assert_not_contains "$NS/test/ref-string/index.html" '{{ ref_string }}' +} +@test "pipeline: From attribution is linked to source when TRBLDOC_SRC_REPO is set" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + TRBLDOC_SRC_REPO="https://example.com/repo/blob/main" run_pipeline -s src + assert_contains "$NS/test/simple/index.html" "From <a href='https://example.com/repo/blob/main/src/simple.c#L1'>src/simple.c:1</a>" +} + +@test "pipeline: {{ toc }} is substituted with nav links after rendering" { + stage_fixture "$FIXTURES/c/toc.c" "src/toc.c" + run_pipeline -s src + # After substitution, the chunk output should contain the nav link for the + # namespace itself, and the literal marker should be gone. + assert_contains "$NS/test/toc-chunk/index.html" "/test/toc-chunk" + assert_not_contains "$NS/test/toc-chunk/index.html" '{{ toc }}' +} + +@test "pipeline: main.layout uses {{ title }} two-bracket format" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/main.layout" '{{ title }}' + assert_not_contains "$NS/main.layout" '{{title}}' +} + +@test "pipeline: main.layout uses {{ nav }} format instead of {{> nav}}" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/main.layout" '{{ nav }}' + assert_not_contains "$NS/main.layout" '{{> nav}}' +} + +@test "pipeline: main.layout uses {{ yield }} format instead of {{{yield}}}" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/main.layout" '{{ yield }}' + assert_not_contains "$NS/main.layout" '{{{yield}}}' +} + +@test "pipeline: main.layout uses {{ breadcrumb }} format" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_contains "$NS/main.layout" '{{ breadcrumb }}' + assert_not_contains "$NS/main.layout" '{{breadcrumb}}' +} + +@test "pipeline: old-style {{src_repo}} without spaces is not substituted" { + mkdir -p src + cat > src/old_fmt.c <<'EOF' +/* md +@name test/old-fmt-src-repo +Old format: {{src_repo}} +*/ +void f() {} +EOF + TRBLDOC_SRC_REPO="https://example.com" run_pipeline -s src + assert_contains "$NS/test/old-fmt-src-repo/index.html" '{{src_repo}}' +} + +@test "pipeline: old-style {{toc}} without spaces is not substituted" { + mkdir -p src + cat > src/old_fmt_toc.c <<'EOF' +/* md +@name test/old-fmt-toc +Old format: {{toc}} +*/ +void f() {} +EOF + run_pipeline -s src + assert_contains "$NS/test/old-fmt-toc/index.html" '{{toc}}' +} + +# ── known defects (skipped – convert to active tests after bug fixes) ──────── + +@test "pipeline: chunk without @name uses source file path as namespace" { + # A chunk with no @name should use its own @file value (the source file path), + # not whatever file was last iterated in the scan loop. + mkdir -p src + cat > src/anon.c <<'EOF' +/* md +Chunk with no @name directive. +*/ +void anon() {} +EOF + run_pipeline -s src + # Expect a namespace folder derived from src/anon.c. + assert_dir_exists "$NS/src/anon.c" +} + +@test "pipeline: second run does not duplicate nav entries" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + run_pipeline -s src # second run + local count + count=$(grep -c "test/simple" "$NS/nav.partial" || true) + assert_equal "$count" "1" +} + +# ── logic bug regression tests ──────────────────────────────────────────── + +@test "pipeline: source folder with spaces in name is discovered via -s" { + # Bug: infolders is a space-separated string; folder names with spaces break discovery. + mkdir -p "src folder" + stage_fixture "$FIXTURES/c/simple.c" "src folder/simple.c" + run_pipeline -s "src folder" + assert_dir_exists "$NS/test/simple" +} + +@test "pipeline: source filename with spaces is processed correctly" { + # Bug: source_files and chunk_files use unquoted iteration which breaks on spaces. + # This fixture has no @name directive, so namespace is derived from source path. + mkdir -p src + cat > 'src/simple file.c' <<'EOF' +/* md +Hello from a C comment. +*/ +int main() { return 0; } +EOF + run_pipeline -s src + # Namespace is derived from source file path (no @name means use file path) + assert_file_exists "$NS/src/simple file.c/index.html" + assert_contains "$NS/src/simple file.c/index.html" "Hello from a C comment" +} + +@test "pipeline: nav entries with ampersands in {{ toc }} are substituted correctly" { + # Bug: toc_oneline sed substitution treats & as matched text. + mkdir -p src + cat > 'src/a.c' <<'EOF' +/* md +@name test/a +Navigation: {{ toc }} +*/ +int a() {} +EOF + cat > 'src/b.c' <<'EOF' +/* md +@name test/b&c +Chunk with ampersand. +*/ +int b() {} +EOF + run_pipeline -s src + # Verify the nav.partial contains the entries + assert_contains "$NS/nav.partial" "test/b&c" + # Verify the rendered output preserves this (not garbled by sed) + assert_contains "$NS/test/a/index.html" "test/b&c" + assert_not_contains "$NS/test/a/index.html" '{{ toc }}' +} + +# ── readme.md → top-level index ───────────────────────────────────────────── + +@test "pipeline: readme.md creates root-level index.html" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_file_exists "$NS/index.html" +} + +@test "pipeline: readme.md content appears in root index.html" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_contains "$NS/index.html" "Hello from the readme" +} + +@test "pipeline: README.md (uppercase) creates root-level index.html" { + mkdir -p src + cat > src/README.md <<'EOF' +# Overview +Uppercase README content. +EOF + run_pipeline -s src + assert_file_exists "$NS/index.html" + assert_contains "$NS/index.html" "Uppercase README content" +} + +@test "pipeline: readme.md with explicit @name uses custom namespace, not root" { + mkdir -p src + cat > src/readme.md <<'EOF' +@name custom/readme +# Custom Name +Content with custom name. +EOF + run_pipeline -s src + assert_file_exists "$NS/custom/readme/index.html" + if [[ -f "$NS/index.html" ]]; then + echo "ASSERTION FAILED: unexpected $NS/index.html created for readme with @name" >&2 + return 1 + fi +} + +@test "pipeline: root index.html from readme.md contains rendered HTML tags" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_contains "$NS/index.html" "<p" +} + +@test "pipeline: root index.html from readme.md passes HTML fragment validation" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_valid_html_fragment "$NS/index.html" +} + +@test "pipeline: nav.partial contains root href when readme.md is present" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_contains "$NS/nav.partial" "href='/'" +} + +@test "pipeline: global.meta contains entry for index when readme.md is present" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_contains "$NS/global.meta" "index:" +} + +@test "pipeline: readme.md alongside other source files produces both root index and other pages" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + assert_file_exists "$NS/index.html" + assert_file_exists "$NS/test/simple/index.html" + assert_contains "$NS/index.html" "Hello from the readme" + assert_contains "$NS/test/simple/index.html" "Hello from a C comment" +} + +@test "pipeline: root index.html source attribution points to readme.md" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + assert_contains "$NS/index.html" "readme.md" +} + +@test "pipeline: readme.md is assembled to the built root, not a subdirectory" { + stage_fixture "$FIXTURES/md/readme.md" "src/readme.md" + run_pipeline -s src + # Must land at the built root, not inside an "index" subdirectory. + assert_file_exists ".trblcache/built/index.html" + if [[ -f ".trblcache/built/index/index.html" ]]; then + echo "ASSERTION FAILED: readme was built to built/index/index.html instead of built/index.html" >&2 + return 1 + fi +} + +# ── known defects + +@test "pipeline: second run does not duplicate index.html content" { + stage_fixture "$FIXTURES/c/simple.c" "src/simple.c" + run_pipeline -s src + run_pipeline -s src + local count + count=$(grep -c "Hello from a C comment" "$NS/test/simple/index.html" || true) + assert_equal "$count" "1" +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100644 index 0000000..4569be3 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# tests/run_tests.sh +# Entry point for the trbldoc test suite. +# Requires bats-core: https://github.com/bats-core/bats-core +# +# Quick install (pick one): +# npm install -g bats +# brew install bats-core +# git clone https://github.com/bats-core/bats-core ~/.bats && ~/.bats/install.sh /usr/local +# +# Usage: +# bash tests/run_tests.sh # run all tests +# bash tests/run_tests.sh unit # run only unit tests +# bash tests/run_tests.sh integration # run only integration tests + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME:-${BASH_SOURCE[0]}}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# ── bats check ────────────────────────────────────────────────────────────── +if ! command -v bats &>/dev/null; then + echo "ERROR: bats is not installed or not on PATH." >&2 + echo "Install with one of:" >&2 + echo " npm install -g bats" >&2 + echo " brew install bats-core" >&2 + echo " git clone https://github.com/bats-core/bats-core ~/.bats && ~/.bats/install.sh /usr/local" >&2 + exit 1 +fi + +# ── suite selection ────────────────────────────────────────────────────────── +TARGET="${1:-all}" + +case "$TARGET" in + unit) + SUITE_DIRS=("$SCRIPT_DIR/unit") + ;; + integration) + SUITE_DIRS=("$SCRIPT_DIR/integration") + ;; + all) + SUITE_DIRS=("$SCRIPT_DIR/unit" "$SCRIPT_DIR/integration") + ;; + *) + echo "Usage: $0 [all|unit|integration]" >&2 + exit 1 + ;; +esac + +# Export so bats test files can locate project files +export REPO_ROOT + +echo "Running trbldoc test suite (target: $TARGET)" +echo "bats $(bats --version)" +echo "" + +bats --print-output-on-failure "${SUITE_DIRS[@]}" diff --git a/tests/unit/directives.bats b/tests/unit/directives.bats new file mode 100644 index 0000000..3a57951 --- /dev/null +++ b/tests/unit/directives.bats @@ -0,0 +1,235 @@ +#!/usr/bin/env bats +# tests/unit/directives.bats +# Verifies that chunk directive parsing (the awk one-liners used in the chunk +# loop of trbldoc.sh) correctly reads @name, @exec, @file, and @ref, and that +# the sed strip pass removes directives from chunk bodies without touching +# non-directive body lines. + +load "../helpers/common.bash" + +FIXTURES="$BATS_TEST_DIRNAME/../fixtures" + +setup() { + setup_workspace + CACHE=".trblcache" + mkdir -p "$CACHE/chunks" +} + +teardown() { + teardown_workspace +} + +# ── helpers used in this file ─────────────────────────────────────────────── + +# Write a chunk file directly (bypassing extraction) so directive tests +# are independent of extraction correctness. +make_chunk() { + local path="$1" + shift + mkdir -p "$(dirname "$path")" + printf '%s\n' "$@" > "$path" +} + +# Read @name from a chunk file (mirrors trbldoc.sh logic). +parse_name() { + awk '/@name .+/ {print $2}' "$1" +} + +# Read @exec from a chunk file. +parse_exec() { + awk '/@exec .+/ {$1=""; print}' "$1" | sed 's/^ //' +} + +# Read @file from a chunk file. +parse_file() { + awk '/@file .+/ {print $2}' "$1" +} + +# Read @ref from a chunk file. +parse_ref() { + awk '/@ref .+/ {print $2}' "$1" +} +# Parse chunk metadata using the same awk program as trbldoc.sh's +# parse_chunk_metadata() helper. +parse_chunk_metadata_record() { + awk ' +BEGIN { + us = sprintf("%c", 31) + from_p = "" + namespace_p = "" + priority_p = "" + program_p = "" + ref_p = "" +} +/^[ \t]*@file / && from_p == "" { + from_p = $0 + sub(/^[ \t]*@file /, "", from_p) +} +/^[ \t]*@name / && namespace_p == "" { + namespace_p = $2 +} +/^[ \t]*@priority / && priority_p == "" { + priority_p = $2 +} +/^[ \t]*@exec / && program_p == "" { + program_p = $0 + sub(/^[ \t]*@exec /, "", program_p) +} +/^[ \t]*@ref / && ref_p == "" { + ref_p = $2 +} +END { + printf "%s%s%s%s%s%s%s%s%s\n", from_p, us, namespace_p, us, priority_p, us, program_p, us, ref_p +} +' "$1" +} + +# Apply the directive strip (mirrors trbldoc.sh line 231). +strip_directives() { + sed -i "s/^@.*//g" "$1" +} + +# ── @name ─────────────────────────────────────────────────────────────────── + +@test "@name: parsed correctly from chunk" { + local chunk="$CACHE/chunks/test_name" + make_chunk "$chunk" "@file src.c:1" "@exec md" "@name my/namespace" "body text" + local result + result="$(parse_name "$chunk")" + assert_equal "$result" "my/namespace" +} + +@test "@name: absent when directive is missing" { + local chunk="$CACHE/chunks/test_no_name" + make_chunk "$chunk" "@file src.c:1" "@exec md" "body text" + local result + result="$(parse_name "$chunk")" + assert_equal "$result" "" +} + +@test "@name: only the first token after @name is captured" { + local chunk="$CACHE/chunks/test_name_token" + make_chunk "$chunk" "@name some/path extra ignored" + local result + result="$(parse_name "$chunk")" + assert_equal "$result" "some/path" +} + +# ── @exec ─────────────────────────────────────────────────────────────────── + +@test "@exec: renderer name parsed correctly" { + local chunk="$CACHE/chunks/test_exec" + make_chunk "$chunk" "@file src.c:1" "@exec md2html --github" "@name ns" + local result + result="$(parse_exec "$chunk")" + assert_equal "$result" "md2html --github" +} + +@test "@exec: absent when directive is missing" { + local chunk="$CACHE/chunks/test_no_exec" + make_chunk "$chunk" "@file src.c:1" "@name ns" "body" + local result + result="$(parse_exec "$chunk")" + assert_equal "$result" "" +} + +# ── @file ─────────────────────────────────────────────────────────────────── + +@test "@file: source path parsed correctly" { + local chunk="$CACHE/chunks/test_file" + make_chunk "$chunk" "@file src/lib.c:42" "@exec md" "@name ns" + local result + result="$(parse_file "$chunk")" + assert_equal "$result" "src/lib.c:42" +} + +# ── @ref ──────────────────────────────────────────────────────────────────── + +@test "@ref: reference name parsed correctly" { + local chunk="$CACHE/chunks/test_ref" + make_chunk "$chunk" "@file src.c:1" "@exec md" "@name ns" "@ref my/ref-name" + local result + result="$(parse_ref "$chunk")" + assert_equal "$result" "my/ref-name" +} + +@test "@ref: absent when directive is missing" { + local chunk="$CACHE/chunks/test_no_ref" + make_chunk "$chunk" "@file src.c:1" "@exec md" "@name ns" + local result + result="$(parse_ref "$chunk")" + assert_equal "$result" "" +} + +@test "metadata parser: directives with leading spaces or tabs are parsed" { + local chunk="$CACHE/chunks/test_directive_leading_whitespace" + local sep expected result + make_chunk \ + "$chunk" \ + " @file src/lib.c:42" \ + $'\t@name docs/indented' \ + " @priority 99" \ + $' \t@exec md2html --github' \ + " @ref refs/anchor" + sep="$(printf '\037')" + expected="src/lib.c:42${sep}docs/indented${sep}99${sep}md2html --github${sep}refs/anchor" + result="$(parse_chunk_metadata_record "$chunk")" + assert_equal "$result" "$expected" +} + +# ── directive stripping (sed pass) ────────────────────────────────────────── + +@test "strip: @name line is removed from chunk body" { + local chunk="$CACHE/chunks/test_strip_name" + make_chunk "$chunk" "@file src.c:1" "@name my/ns" "real body content" + strip_directives "$chunk" + assert_not_contains "$chunk" "^@name" +} + +@test "strip: @exec line is removed from chunk body" { + local chunk="$CACHE/chunks/test_strip_exec" + make_chunk "$chunk" "@exec md" "real body content" + strip_directives "$chunk" + assert_not_contains "$chunk" "^@exec" +} + +@test "strip: @file line is removed from chunk body" { + local chunk="$CACHE/chunks/test_strip_file" + make_chunk "$chunk" "@file src.c:1" "real body content" + strip_directives "$chunk" + assert_not_contains "$chunk" "^@file" +} + +@test "strip: @ref line is removed from chunk body" { + local chunk="$CACHE/chunks/test_strip_ref" + make_chunk "$chunk" "@ref some/ref" "real body content" + strip_directives "$chunk" + assert_not_contains "$chunk" "^@ref" +} + +@test "strip: non-directive body lines are preserved after stripping" { + local chunk="$CACHE/chunks/test_strip_body" + make_chunk "$chunk" "@name ns" "@exec md" "keep this line" "and this one" + strip_directives "$chunk" + assert_contains "$chunk" "keep this line" + assert_contains "$chunk" "and this one" +} + +@test "strip: directives from all-directives fixture are present before strip" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/directives.c" "$prog" "$CACHE" + # Before stripping, @ref should be present + chunk_contains "$CACHE" "@ref" +} + +@test "strip: body content from all-directives fixture survives strip" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/directives.c" "$prog" "$CACHE" + # Strip all chunks + for f in "$CACHE/chunks/"*; do + strip_directives "$f" + done + chunk_contains "$CACHE" "Directive test body content" +} diff --git a/tests/unit/extraction.bats b/tests/unit/extraction.bats new file mode 100644 index 0000000..21c6784 --- /dev/null +++ b/tests/unit/extraction.bats @@ -0,0 +1,171 @@ +#!/usr/bin/env bats +# tests/unit/extraction.bats +# Verifies that the AWK extraction programs correctly identify doc comment +# boundaries and produce chunk files for each supported language. +# +# Tests operate directly on the AWK programs (via helpers/common.bash) without +# running the full trbldoc.sh pipeline. + +load "../helpers/common.bash" + +FIXTURES="$BATS_TEST_DIRNAME/../fixtures" + +setup() { + setup_workspace + CACHE=".trblcache" + mkdir -p "$CACHE/chunks" +} + +teardown() { + teardown_workspace +} + +# ── C (/* ... */) ────────────────────────────────────────────────────────── + +@test "C: single doc comment produces one chunk" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "1" +} + +@test "C: two doc comments produce two chunks" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/multi_chunk.c" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "2" +} + +@test "C: file with no doc comments produces no chunks" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/no_comments.c" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "0" +} + +@test "C: chunk contains @exec directive for renderer" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE" + chunk_contains "$CACHE" "@exec" +} + +@test "C: chunk contains @file directive with source file name" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE" + chunk_contains "$CACHE" "@file" +} + +@test "C: chunk body lines are captured" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE" + chunk_contains "$CACHE" "Hello from a C comment" +} + +@test "C: non-comment source lines are not captured in chunk" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/simple.c" "$prog" "$CACHE" + # 'int main' is code, not doc + if grep -rl "int main" "$CACHE/chunks" >/dev/null 2>&1; then + echo "FAILED: code line 'int main' was captured in a chunk" >&2 + return 1 + fi +} + +@test "C: single-line /* */ comment produces no chunks" { + local prog + prog="$(awk_prog_c "$CACHE")" + # no_comments.c contains a single-line /* ... */ comment on the first line + run_extraction "$FIXTURES/c/no_comments.c" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "0" +} + +@test "C: two-chunk file produces chunks with distinct content" { + local prog + prog="$(awk_prog_c "$CACHE")" + run_extraction "$FIXTURES/c/multi_chunk.c" "$prog" "$CACHE" + chunk_contains "$CACHE" "First chunk content" + chunk_contains "$CACHE" "Second chunk content" +} + +# ── Lua (--[[ ... ]]) ────────────────────────────────────────────────────── + +@test "Lua: single doc comment produces one chunk" { + local prog + prog="$(awk_prog_lua "$CACHE")" + run_extraction "$FIXTURES/lua/simple.lua" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "1" +} + +@test "Lua: chunk body lines are captured" { + local prog + prog="$(awk_prog_lua "$CACHE")" + run_extraction "$FIXTURES/lua/simple.lua" "$prog" "$CACHE" + chunk_contains "$CACHE" "Hello from a Lua comment" +} + +@test "Lua: non-comment source lines are not captured" { + local prog + prog="$(awk_prog_lua "$CACHE")" + run_extraction "$FIXTURES/lua/simple.lua" "$prog" "$CACHE" + if grep -rl "local x" "$CACHE/chunks" >/dev/null 2>&1; then + echo "FAILED: code line 'local x' was captured in a chunk" >&2 + return 1 + fi +} + +@test "Lua: single-line --[[ ]] comment produces no chunks" { + local prog + prog="$(awk_prog_lua "$CACHE")" + run_extraction "$FIXTURES/lua/single_line.lua" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "0" +} + +# ── Python ('''renderer ... ''') ─────────────────────────────────────────── + +@test "Python: single doc comment produces one chunk" { + local prog + prog="$(awk_prog_py "$CACHE")" + run_extraction "$FIXTURES/py/simple.py" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "1" +} + +@test "Python: chunk body lines are captured" { + local prog + prog="$(awk_prog_py "$CACHE")" + run_extraction "$FIXTURES/py/simple.py" "$prog" "$CACHE" + chunk_contains "$CACHE" "Hello from a Python doc comment" +} + +@test "Python: single-line '''content''' on one line produces no chunks" { + local prog + prog="$(awk_prog_py "$CACHE")" + run_extraction "$FIXTURES/py/single_line.py" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "0" +} + +# ── Markdown (whole file = one chunk) ────────────────────────────────────── + +@test "Markdown: whole file produces exactly one chunk" { + local prog + prog="$(awk_prog_md "$CACHE")" + run_extraction "$FIXTURES/md/simple.md" "$prog" "$CACHE" + assert_equal "$(chunk_count "$CACHE")" "1" +} + +@test "Markdown: chunk contains @exec md directive" { + local prog + prog="$(awk_prog_md "$CACHE")" + run_extraction "$FIXTURES/md/simple.md" "$prog" "$CACHE" + chunk_contains "$CACHE" "@exec md" +} + +@test "Markdown: chunk includes file body content" { + local prog + prog="$(awk_prog_md "$CACHE")" + run_extraction "$FIXTURES/md/simple.md" "$prog" "$CACHE" + chunk_contains "$CACHE" "Hello from a markdown file" +} diff --git a/tests/unit/template_vars.bats b/tests/unit/template_vars.bats new file mode 100644 index 0000000..5d95667 --- /dev/null +++ b/tests/unit/template_vars.bats @@ -0,0 +1,160 @@ +#!/usr/bin/env bats +# tests/unit/template_vars.bats +# Unit tests for template variable substitution patterns. +# +# These tests replicate the escape_sed_pattern_literal logic from trbldoc.sh +# and verify that: +# - the canonical {{ var }} format (two brackets, one space each side) is matched +# - the old {{varname}} format (two brackets, no spaces) is NOT matched +# - the layout-specific patterns ({{ title }}, {{ nav }}, {{ yield }}, +# {{ breadcrumb }}) work correctly in sed address and substitution position + +load "../helpers/common.bash" + +setup() { + setup_workspace +} + +teardown() { + teardown_workspace +} + +# Replicate escape_sed_pattern_literal from trbldoc.sh. +escape_pat() { + printf '%s' "$1" | sed -e 's/[][\\.^$*+?(){}|]/\\&/g' +} + +# ── chunk template variables ────────────────────────────────────────────────── + +@test "template vars: {{ src_repo }} is matched and substituted" { + local pat result + pat=$(escape_pat '{{ src_repo }}') + result=$(printf '%s' 'Link: {{ src_repo }}/file.c' | sed -E "s|$pat|https://example.com|g") + assert_equal "$result" 'Link: https://example.com/file.c' +} + +@test "template vars: {{src_repo}} without spaces is NOT matched" { + local pat result + pat=$(escape_pat '{{ src_repo }}') + result=$(printf '%s' 'Old: {{src_repo}}' | sed -E "s|$pat|https://example.com|g") + assert_equal "$result" 'Old: {{src_repo}}' +} + +@test "template vars: {{ toc }} is matched and substituted" { + local pat result + pat=$(escape_pat '{{ toc }}') + result=$(printf '%s' 'Nav: {{ toc }}' | sed -E "s|$pat|<ul><li>nav</li></ul>|g") + assert_equal "$result" 'Nav: <ul><li>nav</li></ul>' +} + +@test "template vars: {{toc}} without spaces is NOT matched" { + local pat result + pat=$(escape_pat '{{ toc }}') + result=$(printf '%s' 'Old: {{toc}}' | sed -E "s|$pat|<ul><li>nav</li></ul>|g") + assert_equal "$result" 'Old: {{toc}}' +} + +@test "template vars: {{ ref_string }} is matched and substituted" { + local pat result + pat=$(escape_pat '{{ ref_string }}') + result=$(printf '%s' "See: {{ ref_string }}." | sed -E "s|$pat|<a href='/x'>x</a>|g") + assert_equal "$result" "See: <a href='/x'>x</a>." +} + +@test "template vars: {{ref_string}} without spaces is NOT matched" { + local pat result + pat=$(escape_pat '{{ ref_string }}') + result=$(printf '%s' 'Old: {{ref_string}}' | sed -E "s|$pat|<a href='/x'>x</a>|g") + assert_equal "$result" 'Old: {{ref_string}}' +} + +@test "template vars: multiple {{ src_repo }} occurrences are all substituted" { + local pat result + pat=$(escape_pat '{{ src_repo }}') + result=$(printf '%s' '{{ src_repo }}/a and {{ src_repo }}/b' | sed -E "s|$pat|https://r|g") + assert_equal "$result" 'https://r/a and https://r/b' +} + +# ── layout template variables ───────────────────────────────────────────────── + +@test "template vars: layout {{ title }} is matched and substituted" { + local result + result=$(printf '%s' '<title>{{ title }}</title>' \ + | sed -E "s|\\{\\{ title \\}\\}|My Page|g") + assert_equal "$result" '<title>My Page</title>' +} + +@test "template vars: layout {{title}} without spaces is NOT matched" { + local result + result=$(printf '%s' '<title>{{title}}</title>' \ + | sed -E "s|\\{\\{ title \\}\\}|My Page|g") + assert_equal "$result" '<title>{{title}}</title>' +} + +@test "template vars: layout {{ breadcrumb }} is matched and substituted" { + local result + result=$(printf '%s' '<h1>{{ breadcrumb }}</h1>' \ + | sed -E "s|\\{\\{ breadcrumb \\}\\}|Section|g") + assert_equal "$result" '<h1>Section</h1>' +} + +@test "template vars: layout {{breadcrumb}} without spaces is NOT matched" { + local result + result=$(printf '%s' '{{breadcrumb}}' \ + | sed -E "s|\\{\\{ breadcrumb \\}\\}|Section|g") + assert_equal "$result" '{{breadcrumb}}' +} + +@test "template vars: layout {{ nav }} is matched and substituted" { + local result + result=$(printf '%s' '<ul>{{ nav }}</ul>' \ + | sed -E "s|\\{\\{ nav \\}\\}|<li>item</li>|g") + assert_equal "$result" '<ul><li>item</li></ul>' +} + +@test "template vars: layout old {{> nav}} partial syntax is NOT matched by {{ nav }} pattern" { + local result + result=$(printf '%s' '<ul>{{> nav}}</ul>' \ + | sed -E "s|\\{\\{ nav \\}\\}|<li>item</li>|g") + assert_equal "$result" '<ul>{{> nav}}</ul>' +} + +@test "template vars: layout {{ yield }} works as sed address to include a file" { + local tmpbody tmplayout output + tmpbody=$(mktemp) + tmplayout=$(mktemp) + printf '<p>body content</p>\n' > "$tmpbody" + printf '{{ yield }}\n' > "$tmplayout" + output=$(sed -E \ + -e "/\\{\\{ yield \\}\\}/r $tmpbody" \ + -e "/\\{\\{ yield \\}\\}/d" \ + "$tmplayout") + rm -f "$tmpbody" "$tmplayout" + assert_equal "$output" '<p>body content</p>' +} + +@test "template vars: layout old {{{yield}}} triple-brace is NOT matched by {{ yield }} pattern" { + local tmpbody tmplayout output + tmpbody=$(mktemp) + tmplayout=$(mktemp) + printf '<p>body content</p>\n' > "$tmpbody" + printf '{{{yield}}}\n' > "$tmplayout" + output=$(sed -E \ + -e "/\\{\\{ yield \\}\\}/r $tmpbody" \ + -e "/\\{\\{ yield \\}\\}/d" \ + "$tmplayout") + rm -f "$tmpbody" "$tmplayout" + # {{{yield}}} should remain unchanged — the new pattern only matches {{ yield }} + assert_equal "$output" '{{{yield}}}' +} + +@test "template vars: substitution value containing special sed chars is safe" { + # Verify that replacement values with & and \ don't corrupt the output + # (this exercises the escape_sed_replacement_literal path) + local pat esc_repl result + pat=$(escape_pat '{{ src_repo }}') + # Simulate escape_sed_replacement_literal for a URL containing & + esc_repl=$(printf '%s' 'https://example.com/a&b' | sed -e 's/[\\&|]/\\&/g') + result=$(printf '%s' '{{ src_repo }}/path' | sed -E "s|$pat|$esc_repl|g") + assert_equal "$result" 'https://example.com/a&b/path' +} |
