ssh git@grit.deadkey.org man

mailmanpat/jenny · 2026-09-06 09:36:46 +08002026-9-6 +8git clone …

FILES / test / units.sh

NameLast CommitTimestampSize
..
fixtures ad9c389 h test: replace the cloned-blog CI job with a committed fixture 26-08-24 11:38 -
lib 41cf051 h fix(index): restore the original break-marker placement and dimming 26-08-24 22:33 -
compare-golden.sh ad9c389 h test: replace the cloned-blog CI job with a committed fixture 26-08-24 11:38 1 KB
regressions.sh b70d686 h Take the parser's escape-by-default contract 26-09-05 10:21 123 KB
run.sh a346b6b h test: add bash test harness, fixtures and link checker 26-08-20 09:30 392 B
smoke.sh a346b6b h test: add bash test harness, fixtures and link checker 26-08-20 09:30 2 KB
units.sh b70d686 h Take the parser's escape-by-default contract 26-09-05 10:21 20 KB
#!/usr/bin/env bash
# Unit tests for helpers.sh functions in isolation.
source "$(dirname "${BASH_SOURCE[0]}")/lib/assert.bash"
TMPROOT=$(mktemp -d -t jenny-units.XXXXXX)
trap 'rm -rf "$TMPROOT"' EXIT
# helpers.sh expects the util indirection to be resolved.
SED=$(command -v gsed || command -v sed)
AWK=$(command -v gawk || command -v awk)
DATE=$(command -v gdate || command -v date)
GREP=$(command -v ggrep || command -v grep)
SUM=$(command -v md5sum || command -v md5)
export SED AWK DATE GREP SUM
PUBLISH_PATTERN="^[0-9]{4}-[0-9]{2}-[0-9]{2}(.*)"
source "$REPO_ROOT/share/jenny/lib/helpers.sh"
suite "filename helpers"
it "get_full_filename strips the directory"
assert_eq "$(get_full_filename '/a/b/2024-01-01 post.md')" "2024-01-01 post.md"
it "get_filename strips the extension"
assert_eq "$(get_filename '2024-01-01 post.md')" "2024-01-01 post"
it "get_timestamp extracts the date"
assert_eq "$(get_timestamp '2024-01-01 post.md')" "2024-01-01"
it "get_post_date formats as MM/DD"
assert_eq "$(get_post_date '2024-03-05 post.md')" "03/05"
it "get_post_date_int formats as YYYYMMDD"
assert_eq "$(get_post_date_int '2024-03-05 post.md')" "20240305"
suite "draft and schedule detection"
it "an undated file is a draft"
if is_draft "notes.md"; then pass; else fail "expected notes.md to be a draft"; fi
it "a dated file is not a draft"
if is_draft "2024-01-01 post.md"; then fail "dated file wrongly a draft"; else pass; fi
it "a future date is scheduled"
future=$(( $(date +%Y) + 1 ))
if is_scheduled "$future-01-01 post.md"; then pass; else fail "future post not scheduled"; fi
it "a past date is not scheduled"
if is_scheduled "2000-01-01 post.md"; then fail "past post wrongly scheduled"; else pass; fi
suite "get_title"
it "reads the first heading"
printf '# Hello World\n\nBody\n' > "$TMPROOT/a.md"
assert_eq "$(get_title "$TMPROOT/a.md")" "Hello World"
it "ignores headings inside a backtick fence"
printf '# Real Title\n\n```\n# not a title\n```\n' > "$TMPROOT/b.md"
assert_eq "$(get_title "$TMPROOT/b.md")" "Real Title"
it "ignores headings inside a tilde fence"
printf '# Real Title\n\n~~~\n# not a title\n~~~\n' > "$TMPROOT/c.md"
assert_eq "$(get_title "$TMPROOT/c.md")" "Real Title"
it "takes only the first of several headings"
printf '# First\n\n# Second\n' > "$TMPROOT/d.md"
assert_eq "$(get_title "$TMPROOT/d.md")" "First"
it "keeps a title containing shell metacharacters intact"
printf '# Back`tick $(whoami) "quoted" & <angle>\n' > "$TMPROOT/e.md"
assert_eq "$(get_title "$TMPROOT/e.md")" 'Back`tick $(whoami) "quoted" & <angle>'
it "returns empty for a file with no heading"
printf 'no heading here\n' > "$TMPROOT/f.md"
assert_eq "$(get_title "$TMPROOT/f.md")" ""
it "does not treat a #hashtag as a heading"
printf '#nospace is not a heading\n\n# Real\n' > "$TMPROOT/g.md"
assert_eq "$(get_title "$TMPROOT/g.md")" "Real"
suite "escaping"
it "html_escape escapes the five entities"
assert_eq "$(html_escape 'a & b < c > d " e '"'"' f')" 'a &amp; b &lt; c &gt; d &quot; e &#39; f'
it "html_escape escapes ampersands first"
assert_eq "$(html_escape '&lt;')" '&amp;lt;'
it "html_escape passes plain text through"
assert_eq "$(html_escape 'Hello World')" 'Hello World'
it "xml_escape escapes the five entities"
assert_eq "$(xml_escape 'a & b < c > d " e '"'"' f')" 'a &amp; b &lt; c &gt; d &quot; e &apos; f'
it "escaping is safe on an empty string"
assert_eq "$(html_escape '')" ""
suite "record join and split"
it "join_by uses the given delimiter"
assert_eq "$(join_by '|' a b c)" "a|b|c"
it "join_by on a single element returns it unchanged"
assert_eq "$(join_by '|' solo)" "solo"
it "split_list round-trips a joined list"
rec=$(join_by "$JENNY_FS" one two three)
split_list "$rec" out
assert_eq "${#out[@]}" "3"
it "split_list preserves empty trailing fields"
rec=$(join_by "$JENNY_FS" one '' three)
split_list "$rec" out2
assert_eq "${out2[1]}" ""
it "split_list handles a one-element list"
split_list "solo" out3
assert_eq "${#out3[@]}" "1"
suite "tag names"
it "a plain tag is valid"
if is_valid_tag "alpha"; then pass; else fail "alpha rejected"; fi
it "hyphens and underscores are valid"
if is_valid_tag "my_tag-2"; then pass; else fail "my_tag-2 rejected"; fi
it "a traversing tag is rejected"
if is_valid_tag "../evil"; then fail "../evil accepted"; else pass; fi
it "a slash is rejected"
if is_valid_tag "a/b"; then fail "a/b accepted"; else pass; fi
it "a dot-only tag is rejected"
if is_valid_tag ".."; then fail ".. accepted"; else pass; fi
it "an empty tag is rejected"
if is_valid_tag ""; then fail "empty tag accepted"; else pass; fi
suite "pagination arithmetic"
it "get_total_page_count rounds up"
assert_eq "$(get_total_page_count 10 3)" "4"
it "an exact multiple needs no extra page"
assert_eq "$(get_total_page_count 9 3)" "3"
it "a single post is one page"
assert_eq "$(get_total_page_count 1 3)" "1"
suite "path prefixes"
it "the site root needs no prefix"
assert_eq "$(get_url_prefix 0)" "."
it "one level deep goes up once"
assert_eq "$(get_url_prefix 1)" ".."
it "three levels deep goes up three times"
assert_eq "$(get_url_prefix 3)" "../../.."
suite "pagination decisions"
it "a post lands on the right page"
assert_eq "$(page_for_index 1 3)/$(page_for_index 3 3)/$(page_for_index 4 3)/$(page_for_index 10 3)" "1/1/2/4"
it "a full page is a boundary"
if is_page_boundary 3 3 10; then pass; else fail "post 3 of 10 at 3/page should close a page"; fi
it "a partial page mid-run is not a boundary"
if is_page_boundary 2 3 10; then fail "post 2 should not close a page"; else pass; fi
it "the last post is always a boundary"
if is_page_boundary 10 3 10; then pass; else fail "the final post must close a page"; fi
it "the final page is the main index"
if is_main_index 4 4; then pass; else fail "page 4 of 4 is the main index"; fi
it "an earlier page is not the main index"
if is_main_index 2 4; then fail "page 2 of 4 is not the main index"; else pass; fi
suite "main index filling"
it "a short main index needs filling"
if needs_filling 1 3 4; then pass; else fail "1 post at 3/page over 4 pages should fill"; fi
it "a full main index does not"
if needs_filling 3 3 4; then fail "a full page needs no filling"; else pass; fi
it "a single-page blog never fills"
if needs_filling 1 3 1; then fail "one page means nothing to fill from"; else pass; fi
it "fill_count asks for the shortfall"
assert_eq "$(fill_count 1 3 10)" "2"
it "fill_count is capped by what is available"
assert_eq "$(fill_count 1 3 1)" "1"
it "fill_count never goes negative"
assert_eq "$(fill_count 5 3 10)" "0"
it "take_last takes from the tail"
arr=(a b c d e)
assert_eq "$(take_last 2 arr | tr '\n' ' ')" "d e "
it "take_last clamps to the array length"
assert_eq "$(take_last 99 arr | tr '\n' ' ')" "a b c d e "
it "take_last of zero yields nothing"
assert_eq "$(take_last 0 arr)" ""
suite "feed selection"
it "feed_slice reverses to newest-first"
feed=(oldest middle newest)
assert_eq "$(feed_slice 10 feed | tr '\n' ' ')" "newest middle oldest "
it "feed_slice honours the cap"
assert_eq "$(feed_slice 2 feed | tr '\n' ' ')" "newest middle "
it "feed_slice on an empty list yields nothing"
empty=()
assert_eq "$(feed_slice 10 empty)" ""
it "feed_slice of zero yields nothing"
assert_eq "$(feed_slice 0 feed)" ""
suite "records"
it "a post record round-trips its fields"
rec=$(make_record "./post/x.html" "/post/x.html" "My Title" "01/02" "RFC822" "")
assert_eq "$(record_field "$rec" 1)|$(record_field "$rec" 3)|$(record_field "$rec" 7)" \
"./post/x.html|My Title|post"
it "a title with a delimiter-adjacent character survives"
rec=$(make_record "u" "c" 'Quote " and $(cmd) and `tick`' "d" "r" "")
assert_eq "$(record_field "$rec" 3)" 'Quote " and $(cmd) and `tick`'
it "a break record carries its page number"
rec=$(make_break_record "" "3")
assert_eq "$(record_field "$rec" 7)|$(record_field "$rec" 8)" "break|3"
it "a tag record carries the tag name"
rec=$(make_record "u" "c" "t" "d" "r" "alpha")
assert_eq "$(record_field "$rec" 6)" "alpha"
suite "output depth"
it "the main index sits at the root"
assert_eq "$(index_depth_for "")" "0"
it "a tag index sits two levels down"
assert_eq "$(index_depth_for "alpha")" "2"
it "a paginated page sits one level down"
assert_eq "$(page_depth_for "")" "1"
it "a tag's paginated page sits three levels down"
assert_eq "$(page_depth_for "alpha")" "3"
it "depths compose into the right prefixes"
assert_eq "$(get_url_prefix "$(index_depth_for "")")|$(get_url_prefix "$(page_depth_for "alpha")")" \
".|../../.."
suite "URL_STYLE"
it "the default style keeps the .html extension"
URL_STYLE=""
assert_eq "$(post_href post-01)" "/post/post-01.html"
it "an explicit 'html' style keeps it too"
URL_STYLE=html
assert_eq "$(post_href post-01)" "/post/post-01.html"
it "the extensionless style drops it"
URL_STYLE=extensionless
assert_eq "$(post_href post-01)" "/post/post-01"
it "a slug containing a dot is not truncated"
URL_STYLE=extensionless
assert_eq "$(post_href my.post.v2)" "/post/my.post.v2"
it "the html style leaves a dotted slug alone"
URL_STYLE=html
assert_eq "$(post_href my.post.v2)" "/post/my.post.v2.html"
it "an unknown style is rejected rather than guessed at"
URL_STYLE=bogus
out=$(post_href post-01 2>&1); st=$?
assert_failure "$st" "$out"
it "the rejection names the offending value"
assert_contains "$out" "bogus"
it "the output filename always keeps .html"
URL_STYLE=extensionless
assert_eq "$(post_filename post-01)" "post-01.html"
it "the filename is unaffected by the html style too"
URL_STYLE=html
assert_eq "$(post_filename post-01)" "post-01.html"
URL_STYLE=""
suite "markdown: intraword underscores"
# The deep-link id is stripped so these assertions can state the markup they
# are about. Each is checking how a span of text is parsed, not that a
# paragraph carries an anchor -- that has tests of its own, and pinning a
# content hash in every one of these would make them unreadable and would
# break them all again on any wording change.
md() { printf '%s\n' "$1" | "$AWK" -f "$REPO_ROOT/share/jenny/lib/markdown.awk/markdown.awk" \
| "$SED" 's/<p id="p-[0-9a-f]*\(-[0-9]*\)\{0,1\}">/<p>/g'; }
it "an underscore inside a word is literal"
assert_eq "$(md 'node_modules here')" "<p>node_modules here</p>"
it "snake_case survives intact"
assert_eq "$(md 'snake_case_name')" "<p>snake_case_name</p>"
it "a leading underscore still opens emphasis"
assert_eq "$(md 'a _real_ emphasis')" "<p>a <em>real</em> emphasis</p>"
it "intraword and real emphasis coexist on one line"
assert_eq "$(md 'x_y and _z_')" "<p>x_y and <em>z</em></p>"
it "asterisk emphasis is unaffected"
assert_eq "$(md 'a *starred* word')" "<p>a <em>starred</em> word</p>"
it "intraword asterisks still emphasise, as in CommonMark"
assert_eq "$(md 'a*b*c')" "<p>a<em>b</em>c</p>"
it "bold with underscores still works"
assert_eq "$(md 'a __bold__ word')" "<p>a <strong>bold</strong> word</p>"
it "no stray em is emitted for an identifier"
assert_not_contains "$(md 'multi_word_ident text')" "<em>"
suite "markdown: fenced code blocks"
# Whitespace is significant inside <pre>, so the tags are joined to the first
# and last content lines. A newline after the opening tag would render as a
# blank first line in the block.
it "a plain fence produces a code block"
assert_eq "$(md '```
echo hi
```')" "<pre><code>echo hi</code></pre>"
it "no blank line is introduced at the top of a block"
assert_not_contains "$(md '```
echo hi
```')" "<code>
"
it "no blank line at the bottom either"
assert_not_contains "$(md '```
echo hi
```')" "
</code>"
it "interior blank lines are preserved"
assert_contains "$(md '```
one
two
```')" "one
two"
it "the fence markers are not in the output"
assert_not_contains "$(md '```
echo hi
```')" '`'
it "a language tag becomes a class"
assert_contains "$(md '```bash
echo hi
```')" 'class="language-bash"'
it "multiple lines are preserved separately"
out=$(md '```
line one
line two
```')
assert_contains "$out" "line one
line two"
it "HTML inside a fence is escaped"
assert_contains "$(md '```
<b>x</b> & y
```')" "&lt;b&gt;x&lt;/b&gt; &amp; y"
it "markdown inside a fence is left alone"
assert_contains "$(md '```
*not emphasis* and _not_ either
```')" "*not emphasis* and _not_ either"
it "smart quotes do not touch fenced content"
assert_contains "$(md '```
say "quoted" here
```')" 'say "quoted" here'
it "text around a fence still renders"
out=$(md 'Before.
```
code
```
After.')
assert_contains "$out" "<p>Before.</p>"
it "text after a fence is a separate paragraph"
assert_contains "$out" "<p>After.</p>"
it "a tilde fence works too"
assert_contains "$(md '~~~
echo hi
~~~')" "<pre><code>"
it "an empty fence is handled"
assert_contains "$(md '```
```')" "<pre><code>"
it "indented code blocks still work"
assert_contains "$(md 'Text:
indented')" "<pre><code>"
suite "markdown: reference links"
it "an explicit label resolves"
assert_contains "$(md 'See [the docs][d].
[d]: http://example.com')" '<a href="http://example.com">the docs</a>'
it "the definition does not appear in the output"
assert_not_contains "$(md 'See [the docs][d].
[d]: http://example.com')" 'http://example.com<'
it "an implicit label reuses the link text"
assert_contains "$(md 'See [docs][].
[docs]: http://example.com')" '<a href="http://example.com">docs</a>'
it "a shortcut reference resolves"
assert_contains "$(md 'See [docs].
[docs]: http://example.com')" '<a href="http://example.com">docs</a>'
it "labels are case-insensitive"
assert_contains "$(md 'See [Docs][D].
[d]: http://example.com')" 'href="http://example.com"'
it "a title is carried through"
assert_contains "$(md 'See [x][d].
[d]: http://example.com "The Title"')" 'title="The Title"'
it "a definition before its use resolves"
assert_contains "$(md '[d]: http://example.com
See [x][d].')" 'href="http://example.com"'
it "an unresolved reference is left as literal text"
out=$(md 'See [x][nope].')
assert_not_contains "$out" '<a href="">'
it "inline links are unaffected"
assert_contains "$(md 'See [x](http://example.com).')" '<a href="http://example.com">x</a>'
it "footnotes still work alongside references"
out=$(md 'Text[^1] and [x][d].
[^1]: note
[d]: http://example.com')
assert_contains "$out" 'href="http://example.com"'
it "the footnote still renders"
assert_contains "$out" 'fn-item'
it "a definition inside a fence is literal"
assert_contains "$(md '```
[d]: http://example.com
```')" '[d]: http://example.com'
suite "markdown: MMD metadata block"
it "a metadata pair is wrapped, not stripped"
assert_contains "$(md 'Series: Web Typography
# Title')" 'data-key="Series"'
it "the value is exposed as an attribute"
assert_contains "$(md 'Series: Web Typography
# Title')" 'data-value="Web Typography"'
it "the original text is preserved verbatim"
assert_contains "$(md 'Series: Web Typography
# Title')" '>Series: Web Typography</p>'
it "the block carries a class the theme can target"
assert_contains "$(md 'Series: Web Typography
# Title')" 'class="meta"'
it "the body still renders"
# Headings carry an id now, so this asserts the text rather than the exact tag.
assert_contains "$(md 'Series: Web Typography
# Title')" ">Title</h1>"
it "multiple keys each get their own element"
out=$(md 'Series: Web Typography
Author: Pat
# Title')
assert_contains "$out" 'data-key="Series"'
it "the second key is wrapped too"
assert_contains "$out" 'data-key="Author"'
it "tags are wrapped like any other key"
assert_contains "$(md 'tags: alpha beta
# Title')" 'data-key="tags"'
it "a multi-word key is preserved verbatim"
assert_contains "$(md 'Base Header Level: 2
# Title')" 'data-key="Base Header Level"'
# MultiMarkdown makes no attempt to tell metadata from prose that happens to
# start Key: value -- position is the whole rule. Verified against
# multimarkdown 6.6.0, which renders "Note: this is prose." at the top of a
# file as <meta name="note">. Matching that rather than inventing a smarter
# heuristic keeps jenny predictable for anyone who knows MMD.
it "a leading Key: value line is metadata even if it reads as prose"
assert_contains "$(md 'Note: this is ordinary prose.
# Title')" 'data-key="Note"'
it "such a line keeps its text, so nothing is lost either way"
assert_contains "$(md 'Note: this is ordinary prose.
# Title')" '>Note: this is ordinary prose.</p>'
it "a block below the top of the file is prose"
assert_not_contains "$(md '# Title
Series: Web Typography')" 'data-key="Series"'
it "a document with no metadata emits no meta element"
assert_not_contains "$(md '# Title
Body.')" 'class="meta"'
it "attribute values are escaped"
assert_contains "$(md 'Series: A & B <x>
# Title')" 'data-value="A &amp; B &lt;x&gt;"'
suite "markdown: tables"
it "a basic table renders as a table"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<table>"
it "the header row becomes thead"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<thead>"
it "header cells are th"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<th"
it "body cells are td"
assert_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "<td"
it "the separator row is not rendered"
assert_not_contains "$(md '| A | B |
|---|---|
| 1 | 2 |')" "---"
it "cell text is preserved"
out=$(md '| A | B |
|---|---|
| 1 | 2 |')
assert_contains "$out" "A"
it "left alignment is applied"
assert_contains "$(md '| L |
|:--|
| 1 |')" "text-align:left"
it "centre alignment is applied"
assert_contains "$(md '| C |
|:-:|
| 1 |')" "text-align:center"
it "right alignment is applied"
assert_contains "$(md '| R |
|--:|
| 1 |')" "text-align:right"
it "pipes at the row edges are optional"
assert_contains "$(md 'A | B
--- | ---
1 | 2')" "<table>"
it "inline markdown inside a cell is parsed"
assert_contains "$(md '| **bold** |
|---|
| a |')" "<strong>bold</strong>"
it "a link inside a cell is parsed"
assert_contains "$(md '| x |
|---|
| [y](http://e.com) |')" '<a href="http://e.com">y</a>'
it "multiple body rows all render"
out=$(md '| A |
|---|
| 1 |
| 2 |')
assert_eq "$(printf '%s' "$out" | grep -c '<td')" "2"
it "the table closes"
assert_contains "$(md '| A |
|---|
| 1 |')" "</table>"
it "text after a table is a separate paragraph"
assert_contains "$(md '| A |
|---|
| 1 |
After.')" "<p>After.</p>"
it "a pipe in prose is not a table"
assert_not_contains "$(md 'a | b in ordinary prose')" "<table>"
# A cell must handle raw HTML exactly as prose does. That used to mean both
# passed it through; it now means both escape it unless the caller asks for
# passthrough with UNSAFE_HTML=1. What matters is that a cell is not a hole in
# whichever rule is in force.
it "raw html in a cell is escaped, as in prose"
assert_contains "$(md '| <b>x</b> |
|---|
| a |')" "&lt;b&gt;x&lt;/b&gt;"
it "a table inside a fence is literal"
assert_contains "$(md '```
| A |
|---|
```')" "| A |"
suite "markdown: citations"
it "a citation renders an inline reference"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'class="citation"'
it "the citation is numbered"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" '(1)'
it "a locator is carried into the reference"
assert_contains "$(md 'Shown[p. 42][#smith].
[#smith]: John Smith. 2011.')" '(p. 42, 1)'
it "the definition does not render as body text"
assert_not_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" '<p>[#smith]:'
it "a citation list is emitted"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'class="citations"'
it "the citation body appears in the list"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'John Smith. 2011.'
it "markdown inside a citation body is parsed"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. *A Book*. 2011.')" '<em>A Book</em>'
it "the inline reference links to the entry"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'href="#cn:1"'
it "the entry links back to the reference"
assert_contains "$(md 'Shown[#smith].
[#smith]: John Smith. 2011.')" 'href="#cnref:1"'
it "numbering follows first use"
out=$(md 'A[#x] and B[#y].
[#x]: First.
[#y]: Second.')
assert_contains "$out" '(1)'
it "the second citation gets the next number"
assert_contains "$out" '(2)'
it "a repeated citation reuses its number"
out=$(md 'A[#x], B[#y], C[#x].
[#x]: First.
[#y]: Second.')
assert_eq "$(printf '%s' "$out" | grep -o 'class="citation">(1)' | wc -l | tr -d ' ')" "2"
it "footnotes still work alongside citations"
out=$(md 'A[^1] and B[#x].
[^1]: a note
[#x]: a citation')
assert_contains "$out" 'fn-item'
it "the citation still renders too"
assert_contains "$out" 'class="citations"'
it "a citation inside a fence is literal"
assert_contains "$(md '```
[#x]: literal
```')" '[#x]: literal'
summary