Files
small-scripts/tests/test-score-paragraphs.sh
Paul O'Reilly f78d292f05 score-paragraphs: port GOES paragraph scorer as general-purpose script
Required --style, --json machine-readable output, mandatory --dryrun.
Engine unchanged; known limitation documented: already-scored paragraphs
are never re-examined (present in the original).

Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
2026-08-02 21:18:02 +12:00

354 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# test-score-paragraphs.sh — Verify score-paragraphs dryrun behaviour against the spec.
#
# Strategy: all assertions use --dryrun (-n), so no `claude` subprocess is ever
# invoked and no output files are written. Fixtures (a style guide + a sample
# markdown file with pre-embedded score blocks) are generated in a tempdir.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SCORE_PARAGRAPHS="$REPO_ROOT/scripts/score-paragraphs"
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
PASS=0
FAIL=0
assert_contains() {
local label=$1 needle=$2 haystack=$3
if [[ "$haystack" == *"$needle"* ]]; then
printf " ${GREEN}PASS${NC} %s\n" "$label"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s\n" "$label"
printf " expected to contain: %s\n" "$needle"
printf " actual output:\n"
printf '%s\n' "$haystack" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
}
assert_not_contains() {
local label=$1 needle=$2 haystack=$3
if [[ "$haystack" != *"$needle"* ]]; then
printf " ${GREEN}PASS${NC} %s\n" "$label"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s\n" "$label"
printf " expected NOT to contain: %s\n" "$needle"
FAIL=$((FAIL + 1))
fi
}
assert_exit_code() {
local label=$1 expected=$2 actual=$3
if [[ "$expected" == "$actual" ]]; then
printf " ${GREEN}PASS${NC} %s (exit=%s)\n" "$label" "$actual"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s (expected exit=%s, got exit=%s)\n" "$label" "$expected" "$actual"
FAIL=$((FAIL + 1))
fi
}
assert_eq() {
local label=$1 expected=$2 actual=$3
if [[ "$expected" == "$actual" ]]; then
printf " ${GREEN}PASS${NC} %s\n" "$label"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} %s\n" "$label"
printf " expected: %s\n" "$expected"
printf " actual: %s\n" "$actual"
FAIL=$((FAIL + 1))
fi
}
# === Pre-flight ===
if [[ ! -x "$SCORE_PARAGRAPHS" ]]; then
echo "score-paragraphs not found or not executable at $SCORE_PARAGRAPHS" >&2
exit 1
fi
# === Fixture setup ===
TMPDIR_TEST="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_TEST"' EXIT
STYLE_FILE="${TMPDIR_TEST}/style.md"
SAMPLE_FILE="${TMPDIR_TEST}/sample.md"
SAMPLE2_FILE="${TMPDIR_TEST}/sample2.md"
JSON_OUT="${TMPDIR_TEST}/scores.json"
cat > "$STYLE_FILE" <<'EOF'
# Test style guide
Direct, evidence-backed, practitioner voice. No hedging, no buzzwords.
EOF
# PARA_ALREADY_SCORED: long enough to score (32 words), immediately followed
# by a score block in the script's own write format (zero blank-line
# separation) whose embedded hash matches it exactly. Per the ported
# engine's verified behaviour (see "Known limitation" in the spec), a
# paragraph in this exact shape is absorbed into the surrounding "skip"
# region by the chunker and is never re-examined for scoring again -- not
# because its hash matched, but because it is no longer seen as `content`
# at all. This is intentionally NOT a "skip unchanged" case to assert on;
# it is a "does not appear anywhere" case.
PARA_ALREADY_SCORED="This paragraph is intentionally long enough to clear the twenty five word minimum threshold used by the scoring engine, discussing evidence and voice and rhythm at some length purely for fixture purposes."
# PARA_ALREADY_SCORED_STALE: same shape, but the attached score block's hash
# deliberately does NOT match the paragraph text. Locks in that this makes
# no difference: it is absorbed into "skip" exactly like the matching-hash
# case above, so a *changed* already-scored paragraph is not re-scored
# either -- this is the concrete, load-bearing part of the limitation.
PARA_ALREADY_SCORED_STALE="Here is a second long paragraph included specifically to exercise the changed hash code path, since its embedded score block below will intentionally carry a hash that does not match this paragraph text at all today."
# PARA_NEW: long enough to score (>=25 words) and has no score block
# attached at all -> the one paragraph in this fixture that genuinely can
# be scored, i.e. "would score" under --dryrun.
PARA_NEW="This is a genuinely new paragraph that has never been scored before and easily clears the minimum word count threshold required for scoring eligibility in this fixture file today."
cat > "$SAMPLE_FILE" <<EOF
---
title: Sample
---
# Chapter One
${PARA_ALREADY_SCORED}
> \`◈\` E:2 · J:2 · V:2 · R:2 · G:2 = **10/15** \`¶af539c2\`
> ⚑ *hedge* · _tighten the opening sentence_
Too short.
${PARA_ALREADY_SCORED_STALE}
> \`◈\` E:1 · J:1 · V:1 · R:1 · G:1 = **5/15** \`¶0000000\`
${PARA_NEW}
| a | b |
|---|---|
| 1 | 2 |
\`\`\`python
print("this code block must never be scored")
\`\`\`
EOF
# A second, small file with one fresh (never-scored) eligible paragraph, for
# multi-file dryrun coverage.
PARA_D="This is a separate file containing a single paragraph that easily clears the minimum word count threshold and has never been scored before at all."
cat > "$SAMPLE2_FILE" <<EOF
${PARA_D}
EOF
run() {
"$SCORE_PARAGRAPHS" "$@" 2>&1
}
ORIG_SAMPLE_CHECKSUM="$(sha256sum "$SAMPLE_FILE" | awk '{print $1}')"
echo "=== score-paragraphs tests ==="
echo
# === Test 1: --help ===
echo "Test 1: --help"
out=$(run --help)
rc=$?
assert_exit_code "--help exits 0" 0 "$rc"
assert_contains "--help shows script name" "score-paragraphs" "$out"
assert_contains "--help shows --style" "--style" "$out"
assert_contains "--help shows --dryrun" "--dryrun" "$out"
assert_contains "--help shows --json" "--json" "$out"
assert_contains "--help shows --force" "--force" "$out"
assert_contains "--help shows --parallel" "--parallel" "$out"
# --book (GOES chapter/appendix discovery) was dropped from this generalised
# port -- it must not be a recognised flag.
book_out=$(run --style "$STYLE_FILE" --dryrun --book "$SAMPLE_FILE")
book_rc=$?
assert_exit_code "--book is rejected (unrecognized argument)" 2 "$book_rc"
assert_contains "--book rejection mentions unrecognized argument" "unrecognized argument" "$book_out"
# === Test 2: basic dryrun over the fixture ===
echo
echo "Test 2: basic dryrun"
out=$(run --style "$STYLE_FILE" --dryrun "$SAMPLE_FILE")
rc=$?
assert_exit_code "dryrun exits 0" 0 "$rc"
assert_contains "shows dryrun header" "[dryrun]" "$out"
assert_contains "shows style path" "$STYLE_FILE" "$out"
assert_contains "reports would-score paragraph (the fresh one)" "[would score]" "$out"
assert_contains "reports too-short paragraph" "[skip too-short]" "$out"
assert_contains "shows the would-be claude invocation" "would run: claude -p" "$out"
assert_contains "shows run total" "Total:" "$out"
assert_not_contains "dryrun never calls claude" "Scoring error" "$out"
# Only PARA_NEW is eligible; the two already-scored paragraphs (matching AND
# stale hash) are absorbed by the chunker and never reach the paragraph walk
# at all -- see "Known limitation" in specs/score-paragraphs.spec.md.
assert_contains "reports exactly 1 would-be-scored paragraph" "1 would be scored" "$out"
assert_contains "reports 0 unchanged (hash-skip is unreachable)" "0 unchanged" "$out"
assert_not_contains "already-scored paragraph hash never appears" "af539c2" "$out"
assert_not_contains "already-scored (stale) paragraph never resurfaces" "0000000" "$out"
# === Test 3: dryrun makes no filesystem changes ===
echo
echo "Test 3: dryrun writes nothing"
run --style "$STYLE_FILE" --dryrun "$SAMPLE_FILE" >/dev/null 2>&1
NEW_CHECKSUM="$(sha256sum "$SAMPLE_FILE" | awk '{print $1}')"
assert_eq "input file untouched by dryrun" "$ORIG_SAMPLE_CHECKSUM" "$NEW_CHECKSUM"
DEFAULT_OUTPUT="${TMPDIR_TEST}/sample-scored.md"
if [[ -e "$DEFAULT_OUTPUT" ]]; then
printf " ${RED}FAIL${NC} dryrun must not create the default output file\n"
FAIL=$((FAIL + 1))
else
printf " ${GREEN}PASS${NC} dryrun did not create the default output file\n"
PASS=$((PASS + 1))
fi
# === Test 4: dryrun works with no claude binary on PATH at all ===
echo
echo "Test 4: dryrun does not require claude on PATH"
out=$(PATH="/usr/bin:/bin" "$SCORE_PARAGRAPHS" --style "$STYLE_FILE" --dryrun "$SAMPLE_FILE" 2>&1)
rc=$?
assert_exit_code "dryrun without claude on PATH still exits 0" 0 "$rc"
assert_not_contains "no 'claude not found' error under dryrun" "claude' not found" "$out"
# === Test 5: real (non-dryrun) run requires claude on PATH, fails before touching files ===
echo
echo "Test 5: non-dryrun run with claude missing fails closed"
out=$(PATH="/usr/bin:/bin" "$SCORE_PARAGRAPHS" --style "$STYLE_FILE" "$SAMPLE_FILE" 2>&1)
rc=$?
assert_exit_code "missing claude exits 1" 1 "$rc"
assert_contains "missing claude error message" "'claude' not found on PATH" "$out"
NEW_CHECKSUM2="$(sha256sum "$SAMPLE_FILE" | awk '{print $1}')"
assert_eq "input file untouched when claude missing" "$ORIG_SAMPLE_CHECKSUM" "$NEW_CHECKSUM2"
# === Test 6: --force does NOT resurrect already-scored paragraphs ===
# This locks in the verified (if surprising) real behaviour: --force only
# widens the hash-comparison inside _jobs_for_file, but that function only
# ever sees `content`-classified chunks, and already-scored paragraphs are
# not `content` chunks by the time --force would matter. See "Known
# limitation" in specs/score-paragraphs.spec.md.
echo
echo "Test 6: --force does not resurrect already-scored paragraphs"
out=$(run --style "$STYLE_FILE" --dryrun --force "$SAMPLE_FILE")
rc=$?
assert_exit_code "force dryrun exits 0" 0 "$rc"
assert_contains "still exactly 1 would-be-scored paragraph with --force" "1 would be scored" "$out"
assert_not_contains "already-scored paragraph still invisible under --force" "af539c2" "$out"
assert_not_contains "stale already-scored paragraph still invisible under --force" "0000000" "$out"
# === Test 7: -n shorthand ===
echo
echo "Test 7: -n shorthand"
out=$(run --style "$STYLE_FILE" -n "$SAMPLE_FILE")
rc=$?
assert_exit_code "-n shorthand exits 0" 0 "$rc"
assert_contains "-n shows dryrun header" "[dryrun]" "$out"
# === Test 8: dryrun + --json - emits valid JSON to stdout only ===
echo
echo "Test 8: dryrun + --json - (stdout)"
json_out=$("$SCORE_PARAGRAPHS" --style "$STYLE_FILE" --dryrun --json - "$SAMPLE_FILE" 2>/dev/null)
rc=$?
assert_exit_code "json-to-stdout dryrun exits 0" 0 "$rc"
if printf '%s' "$json_out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then
printf " ${GREEN}PASS${NC} stdout is valid JSON when --json -\n"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${NC} stdout is not valid JSON when --json -\n"
printf '%s\n' "$json_out" | sed 's/^/ /'
FAIL=$((FAIL + 1))
fi
assert_contains "JSON marks dryrun true" '"dryrun": true' "$json_out"
assert_contains "JSON has a would_score action" '"action": "would_score"' "$json_out"
assert_contains "JSON would_score entries have null scores" '"scores": null' "$json_out"
assert_contains "JSON paragraphs_scored counts only the fresh paragraph" '"paragraphs_scored": 1' "$json_out"
# "reused" is defined in the schema but unreachable via this fixture (or any
# file this script itself wrote) -- see "Known limitation" in the spec.
assert_not_contains "no reused action for this fixture (hash-skip is unreachable)" '"action": "reused"' "$json_out"
# Progress lines must NOT pollute stdout when --json - is used.
stderr_out=$("$SCORE_PARAGRAPHS" --style "$STYLE_FILE" --dryrun --json - "$SAMPLE_FILE" 2>&1 1>/dev/null)
assert_contains "progress lines move to stderr with --json -" "[dryrun]" "$stderr_out"
# === Test 9: dryrun + --json <file> does not write the file ===
echo
echo "Test 9: dryrun + --json FILE (no write)"
out=$(run --style "$STYLE_FILE" --dryrun --json "$JSON_OUT" "$SAMPLE_FILE")
rc=$?
assert_exit_code "dryrun json-to-file exits 0" 0 "$rc"
assert_contains "announces it would write the JSON file" "would write JSON results to" "$out"
if [[ -e "$JSON_OUT" ]]; then
printf " ${RED}FAIL${NC} --json FILE must not be written under --dryrun\n"
FAIL=$((FAIL + 1))
else
printf " ${GREEN}PASS${NC} --json FILE not written under --dryrun\n"
PASS=$((PASS + 1))
fi
# === Test 10: multiple input files in one dryrun invocation ===
echo
echo "Test 10: multiple input files"
out=$(run --style "$STYLE_FILE" --dryrun "$SAMPLE_FILE" "$SAMPLE2_FILE")
rc=$?
assert_exit_code "multi-file dryrun exits 0" 0 "$rc"
assert_contains "mentions first file" "$SAMPLE_FILE" "$out"
assert_contains "mentions second file" "$SAMPLE2_FILE" "$out"
# === Test 11: error — --output with multiple input files ===
echo
echo "Test 11: error — --output with multiple files"
out=$(run --style "$STYLE_FILE" --dryrun --output "${TMPDIR_TEST}/out.md" "$SAMPLE_FILE" "$SAMPLE2_FILE")
rc=$?
assert_exit_code "multi-file --output exits 1" 1 "$rc"
assert_contains "multi-file --output error" "only be used with a single input file" "$out"
# === Test 12: error — input file not found ===
echo
echo "Test 12: error — input file not found"
out=$(run --style "$STYLE_FILE" --dryrun "${TMPDIR_TEST}/does-not-exist.md")
rc=$?
assert_exit_code "missing input file exits 1" 1 "$rc"
assert_contains "missing input file error" "file not found" "$out"
# === Test 13: error — style guide not found ===
echo
echo "Test 13: error — style guide not found"
out=$(run --style "${TMPDIR_TEST}/no-such-style.md" --dryrun "$SAMPLE_FILE")
rc=$?
assert_exit_code "missing style file exits 1" 1 "$rc"
assert_contains "missing style file error" "style guide not found" "$out"
# === Test 14: error — --style is required ===
echo
echo "Test 14: error — --style missing entirely"
out=$(run --dryrun "$SAMPLE_FILE")
rc=$?
assert_exit_code "missing --style exits 2 (argparse required-arg error)" 2 "$rc"
assert_contains "missing --style mentions the flag" "--style" "$out"
# === Test 15: error — no input files given ===
echo
echo "Test 15: error — no positional files"
out=$(run --style "$STYLE_FILE" --dryrun)
rc=$?
assert_exit_code "no files exits 2 (argparse required-arg error)" 2 "$rc"
# === Summary ===
echo
echo "================================"
TOTAL=$((PASS + FAIL))
if [[ "$FAIL" -eq 0 ]]; then
printf "${GREEN}All %d tests passed${NC}\n" "$TOTAL"
exit 0
else
printf "${RED}%d of %d tests failed${NC}\n" "$FAIL" "$TOTAL"
exit 1
fi