patchette

tests: EXPECT, one assertion primitive with a required -- separator

commit f61b01a15565ac25803d3fd4dbdf1c1ce99767d6 · parent 94fd195743 · browse this tree
Ximianth, 2026-10-18 12:13:19 UTC

Replaces the pass/fail helpers, expect_refusal, and the five-line
if/then/pass/else/fail blocks with one call shape:

    EXPECT success <desc> -- <cmd>...
    EXPECT refusal <desc> <message-fragment> -- <cmd>...
    EXPECT equal   <desc> -- <got> <want>

EXPECT records a failure and continues (per pretty widespread language,
we may also introduce an ASSERT, which would more aggressively abort).
Setup failures still abort via set -e.

The "--" is required, so a missing or extra argument at a call site is
an obvious bug that exits aggressively with a clear error,
rather than a confusing command-not-found FAIL.

Commands run with output captured and shown only on failure,
which reduces the ">/dev/null" noise from many call sites.

Same checks as before, with one split: the combined two-branch tip check
is now one EXPECT equal per branch, so a failure names the branch and
shows got/want.  Observed failing correctly: neutering verify's tip
comparison, and dropping the no-final-newline flag at replay.
diff --git a/tests/lib.sh b/tests/lib.sh
index d8a8501..c91c667 100644
--- a/tests/lib.sh
+++ b/tests/lib.sh
@@ -22,42 +22,94 @@ PATCHETTE=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/patchette
 
 RESULT=0
 
-## Report one passing check.
+## EXPECT is the single assertion primitive for tests.
 ##
-## - @param $@ description
-pass() {
+## A failed expectation prints a FAIL line, marks the script failed, and lets
+## the test continue; setup failures, by contrast, abort via set -e.
+## (Hence EXPECT, not ASSERT: an ASSERT would abort.)
+##
+##   EXPECT success <desc> -- <cmd>...
+##   EXPECT refusal <desc> <message-fragment> -- <cmd>...
+##   EXPECT equal   <desc> -- <got> <want>
+##
+## The `--` is required: it marks where EXPECT's own arguments end,
+## and checking its position catches a missing or extra argument at the call site.
+## House layout puts the mode and description on one line, ending in `-- \`,
+## and the subject on the next, indented:
+##
+##   EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \
+##   	"$PATCHETTE" verify "$subject" "$patches"
+##
+## `refusal` passes only when the command exits nonzero AND its output contains
+## the fragment: an exit code alone could come from any guard (or any crash),
+## and each test should stay tied to its specific refusal.
+## Commands run with output captured, shown only on failure.
+## They run on the right side of `||`, which disables set -e within them:
+## fine for external commands, but a shell function passed in won't stop at its first failure.
+## Misuse (unknown mode, misplaced `--`, wrong arity) is a harness bug and exits the script.
+##
+## - @param $1 mode: success, refusal, or equal
+## - @param $2 check description
+## - @stdout one PASS or FAIL line
+## - @state RESULT -- set to 1 on failure
+EXPECT() {
+	local mode=${1:-} desc=${2:-} want='' out rc=0
+	shift 2 || _harness_bug "EXPECT: needs at least <mode> <desc>"
+	if [[ $mode == refusal ]]; then
+		[[ $# -ge 1 ]] || _harness_bug "EXPECT refusal '$desc': missing message fragment"
+		want=$1
+		shift
+	fi
+	[[ ${1:-} == -- ]] || _harness_bug "EXPECT $mode '$desc': expected -- where its own arguments end, got '${1:-}'"
+	shift
+
+	case $mode in
+	success)
+		[[ $# -ge 1 ]] || _harness_bug "EXPECT success '$desc': no command after --"
+		out=$("$@" 2>&1) || rc=$?
+		if [[ $rc -eq 0 ]]; then
+			_pass "$desc"
+		else
+			_fail "$desc: exit $rc; output: $out"
+		fi
+		;;
+	refusal)
+		[[ $# -ge 1 ]] || _harness_bug "EXPECT refusal '$desc': no command after --"
+		out=$("$@" 2>&1) || rc=$?
+		if [[ $rc -eq 0 ]]; then
+			_fail "$desc: expected a refusal, but the command succeeded"
+		elif [[ $out != *"$want"* ]]; then
+			_fail "$desc: refused, but not with the expected message '$want'; got: $out"
+		else
+			_pass "$desc"
+		fi
+		;;
+	equal)
+		[[ $# -eq 2 ]] || _harness_bug "EXPECT equal '$desc': needs exactly <got> <want> after --"
+		if [[ $1 == "$2" ]]; then
+			_pass "$desc"
+		else
+			_fail "$desc: got '$1', want '$2'"
+		fi
+		;;
+	*)
+		_harness_bug "EXPECT: unknown mode '$mode'"
+		;;
+	esac
+}
+
+_pass() {
 	echo "PASS  $*"
 }
 
-## Report one failing check and mark the test script failed.
-##
-## - @param $@ description
-## - @state RESULT -- set to 1
-fail() {
+_fail() {
 	echo "FAIL  $*"
 	RESULT=1
 }
 
-## Run a command expected to refuse, and check it does so for the right reason.
-##
-## PASSes only when the command exits nonzero AND its output contains the
-## expected message fragment: an exit code alone could come from any guard
-## (or any crash), and we want each test tied to its specific refusal.
-##
-## - @param $1 check description
-## - @param $2 message fragment the combined output must contain
-## - @param $@ the command
-expect_refusal() {
-	local desc=$1 want=$2 out rc=0
-	shift 2
-	out=$("$@" 2>&1) || rc=$?
-	if [[ $rc -eq 0 ]]; then
-		fail "$desc: expected a refusal, but the command succeeded"
-	elif [[ $out != *"$want"* ]]; then
-		fail "$desc: refused, but not with the expected message '$want'; got: $out"
-	else
-		pass "$desc"
-	fi
+_harness_bug() {
+	>&2 echo "harness bug: $*"
+	exit 2
 }
 
 _TICK=1700000000
diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh
index 7b0c805..e7a5616 100755
--- a/tests/test-failclosed.sh
+++ b/tests/test-failclosed.sh
@@ -29,53 +29,50 @@ clone_layout() {
 # exactly the guard this case is about.
 clone_layout tampered
 sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch"
-expect_refusal "verify fails on a tampered patch" "MISMATCH" \
+EXPECT refusal "verify fails on a tampered patch" "MISMATCH" -- \
 	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
 
 # Import onto an existing branch is refused (before any replay work).
 git clone -q "$work/corpus/upstream" "$work/occupied"
 git -C "$work/occupied" branch docs-polish main
-expect_refusal "import onto an existing branch is refused" "already exists" \
+EXPECT refusal "import onto an existing branch is refused" "already exists" -- \
 	"$PATCHETTE" import "$work/occupied" "$work/pristine"
 
 # A malformed message line is a parse error, never a guess.
 clone_layout badmsg
 sed -i 's/> Do not ask why./| Do not ask why./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch"
-expect_refusal "a malformed message line is a parse error" "malformed message line" \
+EXPECT refusal "a malformed message line is a parse error" "malformed message line" -- \
 	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
 
 # An unknown parent reference is an error.
 clone_layout badparent
 sed -i '1s/.*/parents no-such-slug/' "$TAMPER/$fancy/add-sprocket-docs.patch"
-expect_refusal "an unknown parent reference is an error" "not BASE or an earlier slug" \
+EXPECT refusal "an unknown parent reference is an error" "not BASE or an earlier slug" -- \
 	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
 
 # A duplicate series entry is an error.
 clone_layout dupseries
 echo "note-to-self" >>"$TAMPER/$fancy/STACK"
-expect_refusal "a duplicate series entry is an error" "duplicate series entry" \
+EXPECT refusal "a duplicate series entry is an error" "duplicate series entry" -- \
 	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
 
 # An orphan patch file (nothing would ever read it) is flagged by verify.
 clone_layout orphan
 cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch"
-expect_refusal "an orphan patch file is flagged by verify" "orphan" \
+EXPECT refusal "an orphan patch file is flagged by verify" "orphan" -- \
 	"$PATCHETTE" verify "$work/corpus/dev" "$TAMPER"
 
 # Export never deletes a folder it didn't write.
 mkdir -p "$work/notours/$fancy"
 touch "$work/notours/$fancy/precious.txt"
-expect_refusal "export into a non-patchette folder is refused" "no STACK file" \
+EXPECT refusal "export into a non-patchette folder is refused" "no STACK file" -- \
 	"$PATCHETTE" export "$work/corpus/dev" "$work/notours" "$base" topic/fancy
-if [[ -f "$work/notours/$fancy/precious.txt" ]]; then
-	pass "export left the non-patchette folder untouched"
-else
-	fail "export deleted contents of a non-patchette folder"
-fi
+EXPECT success "export left the non-patchette folder untouched" -- \
+	test -f "$work/notours/$fancy/precious.txt"
 
 # Importing where the recorded base is absent tells the user what to do.
 git init -q -b main "$work/empty"
-expect_refusal "import without the recorded base points at fetching" "fetch from upstream first" \
+EXPECT refusal "import without the recorded base points at fetching" "fetch from upstream first" -- \
 	"$PATCHETTE" import "$work/empty" "$work/pristine"
 
 exit "$RESULT"
diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh
index eec7766..dd1324d 100755
--- a/tests/test-roundtrip.sh
+++ b/tests/test-roundtrip.sh
@@ -17,11 +17,8 @@ for sigil in quote tab; do
 
 	"$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/patches" "$base" topic/fancy docs-polish >/dev/null
 	"$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/again" "$base" topic/fancy docs-polish >/dev/null
-	if diff -r "$p/patches" "$p/again" >/dev/null; then
-		pass "$sigil: export is regeneration-stable (two runs byte-identical)"
-	else
-		fail "$sigil: export is not regeneration-stable"
-	fi
+	EXPECT success "$sigil: export is regeneration-stable (two runs byte-identical)" -- \
+		diff -r "$p/patches" "$p/again"
 
 	# Guard the guard: confirm the flag actually switched the emitted framing,
 	# or this loop would just test the quote dialect twice.
@@ -30,35 +27,23 @@ for sigil in quote tab; do
 	quote) want=$'> The hum is load-bearing.' ;;
 	tab) want=$'\tThe hum is load-bearing.' ;;
 	esac
-	if grep -qxF "$want" "$sample"; then
-		pass "$sigil: emitted message framing matches the dialect"
-	else
-		fail "$sigil: emitted message framing does not match the dialect"
-	fi
+	EXPECT success "$sigil: emitted message framing matches the dialect" -- \
+		grep -qxF "$want" "$sample"
 
-	if "$PATCHETTE" verify "$work/corpus/dev" "$p/patches" >/dev/null; then
-		pass "$sigil: verify passes on a fresh export"
-	else
-		fail "$sigil: verify rejected a fresh export"
-	fi
+	EXPECT success "$sigil: verify passes on a fresh export" -- \
+		"$PATCHETTE" verify "$work/corpus/dev" "$p/patches"
 
 	# The recreate story: upstream has none of our branches; the patches repo
 	# alone must bring them back, byte-exact.
 	git clone -q "$work/corpus/upstream" "$p/fresh"
 	"$PATCHETTE" import "$p/fresh" "$p/patches" >/dev/null
-	got_fancy=$(git -C "$p/fresh" rev-parse refs/heads/topic/fancy)
-	got_docs=$(git -C "$p/fresh" rev-parse refs/heads/docs-polish)
-	if [[ $got_fancy == "$tip_topic_fancy" && $got_docs == "$tip_docs_polish" ]]; then
-		pass "$sigil: import reproduces exact tips"
-	else
-		fail "$sigil: import tips differ: topic/fancy $got_fancy (want $tip_topic_fancy), docs-polish $got_docs (want $tip_docs_polish)"
-	fi
+	EXPECT equal "$sigil: import reproduces the exact topic/fancy tip" -- \
+		"$(git -C "$p/fresh" rev-parse refs/heads/topic/fancy)" "$tip_topic_fancy"
+	EXPECT equal "$sigil: import reproduces the exact docs-polish tip" -- \
+		"$(git -C "$p/fresh" rev-parse refs/heads/docs-polish)" "$tip_docs_polish"
 
-	if git -C "$p/fresh" fsck --strict >/dev/null 2>&1; then
-		pass "$sigil: fsck --strict clean on the replayed repo"
-	else
-		fail "$sigil: fsck --strict rejected the replayed repo"
-	fi
+	EXPECT success "$sigil: fsck --strict clean on the replayed repo" -- \
+		git -C "$p/fresh" fsck --strict
 done
 
 exit "$RESULT"