Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@

### Fixed
- `BASHUNIT_SHARD_INDEX` / `BASHUNIT_SHARD_TOTAL` set directly (for example in `.bashunitrc`) are now validated. Only the `--shard` flag path parsed them, so a zero or non-numeric total reached raw arithmetic and printed a bare `division by 0` shell error while still exiting 0, and an out-of-range index silently reported `No tests found`
- The `assert_date_*` assertions no longer accept unparseable input. They discarded `bashunit::date::to_epoch`'s failure signal, so a raw non-numeric string reached integer comparison: it either crashed with a bare shell error or coerced to epoch 0, which made two equally-invalid values compare equal β€” `assert_date_within_delta "" "" "5"` passed
- `assert_json_equals` no longer reports two invalid (unparseable) JSON strings as equal. It sorted both sides with `jq -S` but never checked jq's exit code, so two differently-invalid inputs both silently sorted to an empty string and compared equal instead of failing
- Parallel runs no longer lose results when two test files in different directories share a filename. Per-test results were bucketed by basename, so the second file overwrote the first β€” silently, with the run still green. A tests/ tree mirroring a src/ tree makes that layout ordinary (#959)
- Coverage no longer counts variable assignments as functions. A line like `URL="https://${host}/api"` was reported as a function, inflating `FNF`/`FNH` in the LCOV report (17 phantom entries in bashunit's own run); when the value also contained a `|`, the malformed record aborted the LCOV writer with a raw bash arithmetic error (#936)
Expand Down
105 changes: 83 additions & 22 deletions src/assert/dates.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,18 @@
function bashunit::date::to_epoch() {
local input="$1"

# An empty string is never a date, and it has to be rejected here rather than
# left to the cascade below, because the two date(1) implementations disagree
# about it: GNU reads `date -d ""` as "now" and exits 0, while on BSD/macOS
# -d is not even a valid option. Falling through therefore resolved two empty
# inputs to the same epoch and compared them equal on Linux while correctly
# failing on macOS. It also matched the all-digits fast path below vacuously
# (an empty string contains no non-digit characters).
if [ -z "$input" ]; then
echo "$input"
return 1
fi

# Already epoch seconds (all digits)
case "$input" in
*[!0-9]*) ;; # contains non-digits, continue to ISO parsing
Expand DownExpand Up@@ -96,13 +108,58 @@ function bashunit::date::to_epoch() {
return 1
}

_BASHUNIT_DATE_EPOCH_OUT=""

##
# Resolves $1 to epoch seconds into _BASHUNIT_DATE_EPOCH_OUT. Fails the current
# assertion and returns 1 if $1 is not a parseable date.
#
# bashunit::date::to_epoch already signals an unparseable date with `return 1`
# (echoing the raw input back rather than a number), but every assert_date_*
# below used to capture only that echoed value with a plain `x="$(...)"` and
# drop the return code -- so the raw, non-numeric string flowed on into an
# unguarded integer comparison or `$(( ))`. That either crashed with a raw
# "integer expression expected" shell error, or silently coerced to 0 and
# produced a wrong answer with no error at all: two equally-unparseable inputs
# compared equal to each other, so `assert_date_within_delta "" "" "5"` passed.
#
# Uses a fixed return slot rather than an outvar name, per bash-style.md's
# preference. All five callers live in this file and read the slot immediately,
# so one slot cannot be clobbered by an interleaved call -- and it avoids the
# `eval` form entirely, whose internal locals can be shadowed by a caller that
# happens to name its outvar the same thing (the bug behind PR #672).
#
# Arguments: $1 - raw date string
# Returns: 0 and sets the slot when parseable, 1 after failing the assertion
#
# Callers MUST chain `|| return 0`, not bare `|| return`: every other failure
# path in this file ends in `fail_with; return`, which returns 0 (fail_with's
# own last command succeeds), so the enclosing test function's exit status
# stays 0 on an assertion failure. Propagating this function's `return 1`
# unchanged would make the *test function* exit non-zero, which
# runner/exec.sh's `[ "$test_exit_code" -ne 0 ]` check reads as a runtime error
# independently of the fail_with already made here -- reporting the one cause
# as both Failed and Error.
##
function bashunit::date::_epoch_or_fail() {
local input=$1
local epoch
if epoch="$(bashunit::date::to_epoch "$input")"; then
_BASHUNIT_DATE_EPOCH_OUT=$epoch
return 0
fi
bashunit::assert::fail_with "" "${input}" "to be" "a valid date"
return 1
}

function assert_date_equals() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ne "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be equal to" "${expected}"
Expand All@@ -115,10 +172,11 @@ function assert_date_equals() {
function assert_date_before() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -ge "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be before" "${expected}"
Expand All@@ -131,10 +189,11 @@ function assert_date_before() {
function assert_date_after() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -le "$expected" ]; then
bashunit::assert::fail_with "" "${actual}" "to be after" "${expected}"
Expand All@@ -147,12 +206,13 @@ function assert_date_after() {
function assert_date_within_range() {
bashunit::assert::should_skip && return 0

local from
from="$(bashunit::date::to_epoch "$1")"
local to
to="$(bashunit::date::to_epoch "$2")"
local actual
actual="$(bashunit::date::to_epoch "$3")"
local from to actual
bashunit::date::_epoch_or_fail "$1" || return 0
from=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
to=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$3" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT

if [ "$actual" -lt "$from" ] || [ "$actual" -gt "$to" ]; then
bashunit::assert::fail_with "" "${actual}" "to be between" "${from} and ${to}"
Expand All@@ -165,10 +225,11 @@ function assert_date_within_range() {
function assert_date_within_delta() {
bashunit::assert::should_skip && return 0

local expected
expected="$(bashunit::date::to_epoch "$1")"
local actual
actual="$(bashunit::date::to_epoch "$2")"
local expected actual
bashunit::date::_epoch_or_fail "$1" || return 0
expected=$_BASHUNIT_DATE_EPOCH_OUT
bashunit::date::_epoch_or_fail "$2" || return 0
actual=$_BASHUNIT_DATE_EPOCH_OUT
local delta="$3"

local diff=$((actual - expected))
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/assert/dates_test.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,3 +163,65 @@ function test_successful_assert_date_equals_with_utc_z_suffix() {
function test_successful_assert_date_equals_with_tz_offset() {
assert_empty "$(assert_date_equals "2023-11-14T12:00:00+0100" "2023-11-14T12:00:00+0100")"
}

# Unparseable input tests
#
# bashunit::date::to_epoch signals an unparseable date with `return 1`, echoing
# the raw input back. Every assert_date_* used to capture only the echoed value
# and ignore that return code, so the raw string flowed on into an unguarded
# integer comparison (assert_date_equals/_before/_after/_within_range) or
# `$(( ))` arithmetic (assert_date_within_delta). Depending on the exact
# garbage, that either crashed with a raw "integer expression expected" shell
# error (a hyphenated string) or silently coerced to 0 and produced a wrong
# answer with no error at all (a plain word, or two equal garbage/empty
# strings comparing "equal" to each other). Each assertion must instead fail
# cleanly, the same way any other invalid input does.

function test_assert_date_equals_fails_cleanly_on_unparseable_expected() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable expected" "not-a-date" "to be" "a valid date")"\
"$(assert_date_equals "not-a-date" "1700000000")"
}

function test_assert_date_equals_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date equals fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_equals "1700000000" "banana")"
}

function test_assert_date_before_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date before fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_before "banana" "1700000000")"
}

function test_assert_date_after_fails_cleanly_on_unparseable_arg() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date after fails cleanly on unparseable arg" "banana" "to be" "a valid date")"\
"$(assert_date_after "banana" "1700000000")"
}

function test_assert_date_within_range_fails_cleanly_on_unparseable_actual() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within range fails cleanly on unparseable actual" "banana" "to be" "a valid date")"\
"$(assert_date_within_range "1600000000" "1800000000" "banana")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_equal_garbage_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two equal garbage strings" "banana" "to be" "a valid date")"\
"$(assert_date_within_delta "banana" "banana" "5")"
}

function test_assert_date_within_delta_does_not_silently_pass_two_empty_strings() {
assert_same\
"$(bashunit::console_results::print_failed_test\
"Assert date within delta does not silently pass two empty strings" "" "to be" "a valid date")"\
"$(assert_date_within_delta "" "" "5")"
}
Loading