ceremony/test/changelog-assemble.test.sh
claude-bot-andresmgsl 2fd9cb7ded test: sentinel rows across the shape predicate, the armed guard, and the assembler
Must-pass and must-fail rows from #182's test plan: the flip shape green
under the sentinel, drift and malformed sentinels red with file named, the
D3 fragment-list assertion, and D5 sentinel survival through consumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:12:30 +00:00

437 lines
13 KiB
Bash

#!/usr/bin/env bash
# Contract tests for bin/changelog-assemble (issue #114). Constructed
# fixture trees, no git repos — the same discipline as
# test/changelog-armed.test.sh. set -u, not -e: failing commands are
# behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/changelog.sh
. "$ROOT/lib/changelog.sh"
TOOL="$ROOT/bin/changelog-assemble"
SECTION="$ROOT/bin/changelog-section"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# tree <name> — a fixture tree with changelog.d/ and its README marker;
# the changelog body arrives on stdin.
tree() {
mkdir -p "$TMP/$1/changelog.d"
printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$TMP/$1/changelog.d/README.md"
cat >"$TMP/$1/CHANGELOG.md"
}
# frag <tree> <name> — a fragment; body on stdin.
frag() {
cat >"$TMP/$1/changelog.d/$2"
}
# The tool reads the consumer's tree at its working directory, so every
# case runs from inside a constructed fixture tree.
in_tree() {
local dir="$1"
shift
(cd "$TMP/$dir" && "$TOOL" "$@")
}
assert_file() {
local file="$1" expected="$2" actual
actual="$(cat "$file")"
[ "$actual" = "$expected" ] || {
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
return 1
}
}
BASE_CHANGELOG=$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
# --- flat write: exact bytes, exact deletions --------------------------------
tree flat-one <<EOF
$BASE_CHANGELOG
EOF
frag flat-one 12.md <<'EOF'
- Twelve landed.
EOF
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
in_tree flat-one 0.2.0 2026-07-24
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
assert_file "$TMP/flat-one/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "flat: the consumed fragment is deleted" 1 "" \
test -e "$TMP/flat-one/changelog.d/12.md"
check "flat: README.md survives consumption" 0 "" \
test -e "$TMP/flat-one/changelog.d/README.md"
# --- publication order: numeric, newest first, cross-repo beside local -------
tree flat-many <<EOF
$BASE_CHANGELOG
EOF
frag flat-many 2.md <<'EOF'
- Two.
EOF
frag flat-many 9.md <<'EOF'
- Nine.
EOF
frag flat-many 10.md <<'EOF'
- Ten.
EOF
frag flat-many ceremony-14.md <<'EOF'
- Fourteen crossed over — naïve reflows would mangle this café's
continuation line, so it must survive verbatim.
EOF
assert_check() {
local dir="$1" expected="$2" actual
actual="$(in_tree "$dir" 0.2.0 2026-07-24 --check)"
[ "$actual" = "$expected" ] || {
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
return 1
}
}
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim.\n- Ten.\n- Nine.\n- Two.'
# --- grouped write: canonical order, unnamed group appended ------------------
tree grouped <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
### Fixed
- The shipped entry.
EOF
frag grouped 21.md <<'EOF'
### Fixed
- Fixed twenty-one.
EOF
frag grouped 20.md <<'EOF'
### Added
- Added twenty.
- Added twenty, second bullet.
### Docs
- Docs twenty.
EOF
frag grouped 19.md <<'EOF'
### Security
- Security nineteen.
### Added
- Added nineteen.
EOF
GROUPED_BODY=$'### Added\n\n- Added twenty.\n- Added twenty, second bullet.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.'
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
assert_check grouped "$GROUPED_BODY"
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
in_tree grouped 0.2.0 2026-07-24
check "grouped: the written file is exact" 0 "" \
assert_file "$TMP/grouped/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.'
# --- the declared anchor: the first grouped release over a flat history ------
# The flip ceremony (#182): a 'grouped' sentinel admits grouped fragments
# over a flat newest published section, the sentinel is never a stray file,
# and it survives the consumption (D5) — the next -dev tree still declares
# its shape.
tree flip <<EOF
$BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
frag flip 40.md <<'EOF'
### Added
- Forty landed.
EOF
check "sentinel: the flip release assembles grouped over a flat published section" 0 \
"consumed 1 fragment" in_tree flip 0.2.0 2026-07-24
check "sentinel: the written flip section is exact" 0 "" \
assert_file "$TMP/flip/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "sentinel: changelog.d/shape survives consumption" 0 "" \
test -e "$TMP/flip/changelog.d/shape"
tree flip-flat-frag <<EOF
$BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
frag flip-flat-frag 41.md <<'EOF'
- Flat forty-one.
EOF
check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \
"changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24
tree flip-malformed <<EOF
$BASE_CHANGELOG
EOF
printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape"
frag flip-malformed 42.md <<'EOF'
### Added
- Forty-two.
EOF
check "sentinel: a malformed sentinel refuses, file named" 1 \
"changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24
# --- a changelog holding only its preamble -----------------------------------
tree preamble-only <<'EOF'
# Changelog
Only preamble so far.
EOF
frag preamble-only 1.md <<'EOF'
- The first entry ever.
EOF
check "a changelog with no section yet gets the section after the preamble" 0 "" \
in_tree preamble-only 0.1.0 2026-07-24
check "preamble-only write is exact" 0 "" \
assert_file "$TMP/preamble-only/CHANGELOG.md" \
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.'
# --- --check is provably read-only -------------------------------------------
tree check-readonly <<EOF
$BASE_CHANGELOG
EOF
frag check-readonly 5.md <<'EOF'
- Five.
EOF
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
check "--check prints the assembled body" 0 "Five." \
in_tree check-readonly 0.2.0 2026-07-24 --check
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
# --- the date defaults to today (UTC) ----------------------------------------
check "date defaults without an argument" 0 "consumed 1 fragment" \
in_tree check-readonly 0.2.0
check "the defaulted stamp is a UTC date" 0 "" \
grep -qE '^## 0\.2\.0 — [0-9]{4}-[0-9]{2}-[0-9]{2}$' "$TMP/check-readonly/CHANGELOG.md"
# --- --changelog and --dir override the defaults -----------------------------
mkdir -p "$TMP/flagged/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md"
check "--changelog and --dir override the defaults" 0 "" \
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags"
check "the flag-driven write landed in the named changelog" 0 "" \
grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md"
# --- refusals: each names the file responsible -------------------------------
tree empty-frags <<EOF
$BASE_CHANGELOG
EOF
rm "$TMP/empty-frags/changelog.d/README.md"
check "an empty directory refuses — a release publishes prose" 1 "zero fragments" \
in_tree empty-frags 0.2.0
check "the empty-directory refusal names the directory" 1 "changelog.d" \
in_tree empty-frags 0.2.0
tree only-readme <<EOF
$BASE_CHANGELOG
EOF
check "a README-only directory refuses with zero fragments" 1 "zero fragments" \
in_tree only-readme 0.2.0
tree no-bullet <<EOF
$BASE_CHANGELOG
EOF
frag no-bullet 3.md <<'EOF'
Prose without a bullet is a heading in spirit.
EOF
check "a fragment with no bullet refuses, file named" 1 \
"fragment 'changelog.d/3.md' has no entries" \
in_tree no-bullet 0.2.0
tree dangling <<EOF
$BASE_CHANGELOG
EOF
frag dangling 4.md <<'EOF'
### Added
### Fixed
- Fixed entry.
EOF
check "a dangling grouped heading refuses, file and heading named" 1 \
"fragment 'changelog.d/4.md' has an empty heading: '### Added'" \
in_tree dangling 0.2.0
tree smuggled <<EOF
$BASE_CHANGELOG
EOF
frag smuggled 6.md <<'EOF'
## 0.2.0 — 2026-07-24
- An entry under a smuggled heading.
EOF
check "a fragment carrying a '## ' line refuses, file named" 1 \
"fragment 'changelog.d/6.md' carries a '## ' heading" \
in_tree smuggled 0.2.0
tree stray-txt <<EOF
$BASE_CHANGELOG
EOF
frag stray-txt 7.md <<'EOF'
- Seven.
EOF
frag stray-txt notes.txt <<'EOF'
A stray scratchpad.
EOF
check "a stray notes.txt refuses, file named" 1 "notes.txt" \
in_tree stray-txt 0.2.0
tree stray-markdown <<EOF
$BASE_CHANGELOG
EOF
frag stray-markdown 12.markdown <<'EOF'
- Wrong extension.
EOF
check "12.markdown refuses on the name pattern" 1 "12.markdown" \
in_tree stray-markdown 0.2.0
tree stray-case <<EOF
$BASE_CHANGELOG
EOF
frag stray-case Fix-12.md <<'EOF'
- Uppercase prefix.
EOF
check "Fix-12.md refuses on the name pattern" 1 "Fix-12.md" \
in_tree stray-case 0.2.0
tree mixed <<EOF
$BASE_CHANGELOG
EOF
frag mixed 5.md <<'EOF'
- Flat five.
EOF
frag mixed 6.md <<'EOF'
### Added
- Grouped six.
EOF
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
in_tree mixed 0.2.0
check "the mixed refusal names the flat side too" 1 "5.md" \
in_tree mixed 0.2.0
tree grouped-over-flat <<EOF
$BASE_CHANGELOG
EOF
frag grouped-over-flat 6.md <<'EOF'
### Added
- Grouped six.
EOF
check "an all-grouped set over a flat published section refuses before assembly" 1 \
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
in_tree grouped-over-flat 0.2.0
tree already <<'EOF'
# Changelog
## 0.2.0 — 2026-07-20
- Already shipped.
EOF
frag already 4.md <<'EOF'
- A late fragment.
EOF
check "an already-present section refuses — the ceremony was already run" 1 \
"already has a section for '0.2.0'" \
in_tree already 0.2.0
# Whole-version matching, as everywhere in this family: an rc section never
# blocks the bare version.
tree rc-present <<'EOF'
# Changelog
## 0.2.0-rc1 — 2026-07-15
- The candidate's entry.
EOF
frag rc-present 8.md <<'EOF'
- The real release entry.
EOF
check "an rc section does not block assembling the bare version" 0 "" \
in_tree rc-present 0.2.0 2026-07-24
mkdir -p "$TMP/no-changelog/changelog.d"
printf -- '- Entry.\n' >"$TMP/no-changelog/changelog.d/2.md"
check "a missing changelog refuses" 1 "no such file" \
in_tree no-changelog 0.2.0
# --- usage errors exit 2 -----------------------------------------------------
check "no arguments is a usage error" 2 "usage:" in_tree flat-one
check "an unknown flag is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --frobnicate
check "a third positional is a usage error" 2 "usage:" in_tree flat-one 0.2.0 2026-07-24 extra
check "--dir without a value is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --dir
# --- round trip: the publisher and the assembler agree by test ---------------
tree round-trip <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
### Fixed
- The shipped entry.
EOF
frag round-trip 30.md <<'EOF'
### Added
- Thirty — wraps onto a
continuation line with a naïve café.
EOF
frag round-trip 29.md <<'EOF'
### Fixed
- Fixed twenty-nine.
EOF
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
check "round trip: write mode succeeds after --check" 0 "" \
in_tree round-trip 0.2.0 2026-07-24
assert_round_trip() {
local published
published="$( (cd "$TMP/round-trip" && "$SECTION" 0.2.0) )"
[ "$published" = "$CHECKED" ] || {
printf -- '--check said:\n%s\nthe publisher said:\n%s\n' "$CHECKED" "$published"
return 1
}
}
check "round trip: bin/changelog-section returns exactly the body --check printed" 0 "" \
assert_round_trip
check "round trip: the assembled section reports no problem" 0 "" \
changelog_section_problem "$TMP/round-trip/CHANGELOG.md" 0.2.0
# --- idempotence: the ceremony cannot run twice ------------------------------
check "a second write over the consumed directory refuses with zero fragments" 1 \
"zero fragments" \
in_tree round-trip 0.2.0 2026-07-24
summary