diff --git a/CHANGELOG.md b/CHANGELOG.md index 857af8d5..cd269484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - `--changed []` runs only the test files git reports as touched since `` (default `origin/HEAD`, then `HEAD`), covering committed, staged, unstaged and untracked changes. Deletions are dropped, a rename selects its new path, and a missing work tree or unresolvable ref fails the run instead of selecting nothing (#1010) +- `assert_between ` and `assert_not_between` add inclusive numeric-range assertions for integers and decimals (#1026) - `--list` (alias `--dry-run`) prints the tests a run would execute, without running them; `--list-format json` emits file, function, name, line and tags. Honours every selection flag, including `--shard` and `--random-order --seed` ordering (#1007) - `--exclude-filter ` skips tests by name, the counterpart of `--exclude-tag`. Repeatable, OR'd, and wins over `--filter` (#1009) - `# @tags a b` above any top-level line applies those tags to every test in the file, unioned with per-function `# @tag` (#1008) @@ -15,6 +16,7 @@ - Performance: `--coverage` is about 1.6x to 2.3x faster. Executable-line classification no longer forks `grep` per source line, which was roughly half of a coverage run's wall time and affected both engines equally (#1005) ### Fixed +- `assert_within_delta` rejects malformed numbers such as `1.2.3` or `5-3` as non-numeric instead of leaking a raw `bc` parse error or silently evaluating them as an expression (#1026) - Report formats are no longer empty under `--parallel`. `--report-junit`, `--report-tap`, `--report-json`, `--report-html` and `--log-junit` all recorded zero tests, because the rows were collected inside the per-test worker and nothing rebuilt them in the parent (#1004) ## [0.45.0](https://github.com/TypedDevs/bashunit/compare/0.44.0...0.45.0) - 2026-08-09 diff --git a/README.md b/README.md index 78f8754b..f68f4e57 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## Why bashunit A lightweight, fast testing framework for **Bash 3.0+**, focused on developer experience. -It ships 75 assertions plus spies, mocks, data providers, snapshots and more. +It ships 77 assertions plus spies, mocks, data providers, snapshots and more. ## Quick start diff --git a/completions/_bashunit b/completions/_bashunit index cf792846..e94caa80 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -17,7 +17,7 @@ _bashunit() { assert_fns=( assert_array_contains assert_array_length assert_array_not_contains assert_arrays_equal assert_assertion_fails assert_assertion_fails_with - assert_assertion_passes assert_command_available assert_command_not_found assert_contains + assert_assertion_passes assert_between assert_command_available assert_command_not_found assert_contains assert_contains_ignore_case assert_date_after assert_date_before assert_date_equals assert_date_within_delta assert_date_within_range assert_directory_exists assert_directory_not_exists assert_duration @@ -34,7 +34,7 @@ _bashunit() { assert_less_or_equal_than assert_less_than assert_line_count assert_match_named_snapshot assert_match_named_snapshot_ignore_colors assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches - assert_not_contains assert_not_empty assert_not_equals assert_not_matches + assert_not_between assert_not_contains assert_not_empty assert_not_equals assert_not_matches assert_not_same assert_same assert_string_ends_with assert_string_matches_format assert_string_not_ends_with assert_string_not_matches_format assert_string_not_starts_with diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 0fc2e0fe..310769f0 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -30,7 +30,7 @@ _BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --changed --coverage --coverage _BASHUNIT_COMPLETIONS_ASSERT_FNS="assert_array_contains assert_array_length \ assert_array_not_contains assert_arrays_equal assert_assertion_fails \ -assert_assertion_fails_with assert_assertion_passes assert_command_available assert_command_not_found \ +assert_assertion_fails_with assert_assertion_passes assert_between assert_command_available assert_command_not_found \ assert_contains assert_contains_ignore_case assert_date_after \ assert_date_before assert_date_equals assert_date_within_delta \ assert_date_within_range assert_directory_exists assert_directory_not_exists \ @@ -47,7 +47,7 @@ assert_json_contains assert_json_equals assert_json_key_exists \ assert_less_or_equal_than assert_less_than assert_line_count \ assert_match_named_snapshot assert_match_named_snapshot_ignore_colors \ assert_match_snapshot assert_match_snapshot_ignore_colors assert_matches \ -assert_not_contains assert_not_empty assert_not_equals assert_not_matches \ +assert_not_between assert_not_contains assert_not_empty assert_not_equals assert_not_matches \ assert_not_same assert_same assert_string_ends_with \ assert_string_matches_format assert_string_not_ends_with \ assert_string_not_matches_format assert_string_not_starts_with \ diff --git a/docs/assertions.md b/docs/assertions.md index 19d0bba0..29ce8a79 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -20,7 +20,7 @@ to narrow it (`bashunit doc json`). |-------|------------| | **Booleans and equality** | [assert_true](#assert-true) · [assert_false](#assert-false) · [assert_same](#assert-same) · [assert_not_same](#assert-not-same) · [assert_equals](#assert-equals) · [assert_not_equals](#assert-not-equals) | | **Strings** | [assert_contains](#assert-contains) · [assert_not_contains](#assert-not-contains) · [assert_contains_ignore_case](#assert-contains-ignore-case) · [assert_matches](#assert-matches) · [assert_not_matches](#assert-not-matches) · [assert_string_starts_with](#assert-string-starts-with) · [assert_string_not_starts_with](#assert-string-not-starts-with) · [assert_string_ends_with](#assert-string-ends-with) · [assert_string_not_ends_with](#assert-string-not-ends-with) · [assert_string_matches_format](#assert-string-matches-format) · [assert_string_not_matches_format](#assert-string-not-matches-format) · [assert_empty](#assert-empty) · [assert_not_empty](#assert-not-empty) · [assert_line_count](#assert-line-count) | -| **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_within_delta](#assert-within-delta) | +| **Numbers** | [assert_less_than](#assert-less-than) · [assert_less_or_equal_than](#assert-less-or-equal-than) · [assert_greater_than](#assert-greater-than) · [assert_greater_or_equal_than](#assert-greater-or-equal-than) · [assert_between](#assert-between) · [assert_not_between](#assert-not-between) · [assert_within_delta](#assert-within-delta) | | **Dates** | [assert_date_equals](#assert-date-equals) · [assert_date_before](#assert-date-before) · [assert_date_after](#assert-date-after) · [assert_date_within_range](#assert-date-within-range) · [assert_date_within_delta](#assert-date-within-delta) | | **Exit codes and commands** | [assert_exit_code](#assert-exit-code) · [assert_successful_code](#assert-successful-code) · [assert_unsuccessful_code](#assert-unsuccessful-code) · [assert_general_error](#assert-general-error) · [assert_command_available](#assert-command-available) · [assert_command_not_found](#assert-command-not-found) · [assert_exec](#assert-exec) | | **Files** | [assert_file_exists](#assert-file-exists) · [assert_file_not_exists](#assert-file-not-exists) · [assert_file_contains](#assert-file-contains) · [assert_file_not_contains](#assert-file-not-contains) · [assert_is_file](#assert-is-file) · [assert_is_file_empty](#assert-is-file-empty) · [assert_is_symlink](#assert-is-symlink) · [assert_is_not_symlink](#assert-is-not-symlink) · [assert_symlink_to](#assert-symlink-to) · [assert_file_permissions](#assert-file-permissions) · [assert_files_equals](#assert-files-equals) · [assert_files_not_equals](#assert-files-not-equals) | @@ -405,6 +405,47 @@ function test_failure() { ``` ::: +## assert_between +> `assert_between "min" "max" "actual"` + +Reports an error if `actual` is outside the inclusive numeric range from `min` to `max`. +Integers, decimals, and negative values are supported. `min` must not be greater than `max`. + +- [assert_not_between](#assert-not-between) is the exact negation and takes the same arguments. + +::: code-group +```bash [Example] +function test_success() { + assert_between "100" "500" "275" + assert_between "0.1" "0.3" "0.2" +} + +function test_failure() { + assert_between "100" "500" "750" +} +``` +::: + +## assert_not_between +> `assert_not_between "min" "max" "actual"` + +Reports an error if `actual` is inside the inclusive numeric range from `min` to `max`. +Integers, decimals, and negative values are supported. `min` must not be greater than `max`. + +- [assert_between](#assert-between) is the exact negation and takes the same arguments. + +::: code-group +```bash [Example] +function test_success() { + assert_not_between "400" "499" "200" +} + +function test_failure() { + assert_not_between "400" "499" "404" +} +``` +::: + ## assert_within_delta > `assert_within_delta "expected" "actual" "delta"` diff --git a/docs/public/bashunit-skill.md b/docs/public/bashunit-skill.md index 7f08f7cc..c44afba6 100644 --- a/docs/public/bashunit-skill.md +++ b/docs/public/bashunit-skill.md @@ -118,14 +118,15 @@ cleaned up automatically and are safe under `--parallel`. ## Assertions -`bashunit doc` prints the full catalogue (75 assertions) locally; `bashunit doc contains` +`bashunit doc` prints the full catalogue (77 assertions) locally; `bashunit doc contains` filters it. The same list is at https://bashunit.com/assertions. **Do not invent names** — a wrong name is a runtime error, not a failed assertion. - Equality: `assert_same`, `assert_not_same`, `assert_equals`, `assert_not_equals` - Strings: `assert_contains`, `assert_not_contains`, `assert_matches`, `assert_string_starts_with`, `assert_string_ends_with`, `assert_empty`, `assert_not_empty` -- Numbers: `assert_greater_than`, `assert_less_than`, `assert_within_delta` +- Numbers: `assert_greater_than`, `assert_less_than`, `assert_between`, + `assert_not_between`, `assert_within_delta` - Exit codes: `assert_successful_code`, `assert_general_error`, `assert_exit_code`, `assert_command_not_found`, `assert_command_available` - Files: `assert_file_exists`, `assert_file_contains`, `assert_is_file_empty`, diff --git a/src/assert/core.sh b/src/assert/core.sh index e0954866..eb3be8c2 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -1031,6 +1031,71 @@ function assert_greater_or_equal_than() { bashunit::state::add_assertions_passed } +## +# Asserts that a numeric value falls inside an inclusive range. +# Arguments: $1 - minimum, $2 - maximum, $3 - actual, $4 - label (optional) +# Returns: 0 after reporting the assertion, 2 for invalid input +## +function assert_between() { + bashunit::assert::should_skip && return 0 + if [ "$#" -lt 3 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "min, max, actual" "$#" + return 2 + fi + + local min="$1" + local max="$2" + local actual="$3" + local label_override="${4:-}" + + if ! bashunit::assert::_validate_range_args "${FUNCNAME[0]}" "$min" "$max" "$actual"; then + return 2 + fi + + if ! bashunit::math::is_le "$min" "$actual"; then + bashunit::assert::fail_with "$label_override" "$actual" "to be between" "$min and $max" \ + "Violated lower bound" "$min" + return + fi + + if ! bashunit::math::is_le "$actual" "$max"; then + bashunit::assert::fail_with "$label_override" "$actual" "to be between" "$min and $max" \ + "Violated upper bound" "$max" + return + fi + + bashunit::state::add_assertions_passed +} + +## +# Asserts that a numeric value falls outside an inclusive range. +# Arguments: $1 - minimum, $2 - maximum, $3 - actual, $4 - label (optional) +# Returns: 0 after reporting the assertion, 2 for invalid input +## +function assert_not_between() { + bashunit::assert::should_skip && return 0 + if [ "$#" -lt 3 ]; then + bashunit::assert::usage_error "${FUNCNAME[0]}" 3 "min, max, actual" "$#" + return 2 + fi + + local min="$1" + local max="$2" + local actual="$3" + local label_override="${4:-}" + + if ! bashunit::assert::_validate_range_args "${FUNCNAME[0]}" "$min" "$max" "$actual"; then + return 2 + fi + + if bashunit::math::is_le "$min" "$actual" && bashunit::math::is_le "$actual" "$max"; then + bashunit::assert::fail_with "$label_override" "$actual" "to not be between" "$min and $max" + return + fi + + bashunit::state::add_assertions_passed +} + ## # Whether a value looks like a number (integer or decimal, optional sign). # Returns: 0 when numeric, 1 otherwise. @@ -1039,14 +1104,49 @@ function bashunit::assert::_is_numeric() { local value="$1" case "$value" in '' | *[!0-9.+-]*) return 1 ;; + -*) value=${value#-} ;; + +*) value=${value#+} ;; esac - # Must contain at least one digit (rejects ".", "-", "+"). + + case "$value" in + '' | '.' | *[+-]*) return 1 ;; + *.*) + local fraction=${value#*.} + case "$fraction" in *.*) return 1 ;; esac + ;; + esac + case "$value" in *[0-9]*) return 0 ;; esac return 1 } +## +# Validates the shared numeric-range contract. +# Arguments: $1 - assertion name, $2 - min, $3 - max, $4 - actual +# Returns: 0 when valid, 1 after emitting a usage error otherwise +## +function bashunit::assert::_validate_range_args() { + local assertion=$1 + local min=$2 + local max=$3 + local actual=$4 + + if ! bashunit::assert::_is_numeric "$min" || + ! bashunit::assert::_is_numeric "$max" || + ! bashunit::assert::_is_numeric "$actual"; then + bashunit::assert::usage_error_detail "$assertion" \ + "expects numeric min, max, and actual values, got '$min', '$max', '$actual'" + return 1 + fi + + if ! bashunit::math::is_le "$min" "$max"; then + bashunit::assert::usage_error_detail "$assertion" "expects min <= max, got '$min' and '$max'" + return 1 + fi +} + ## # Asserts the actual value is within +/- delta of the expected value: # |actual - expected| <= delta. Supports floats via bashunit::math::calculate. diff --git a/src/util/math.sh b/src/util/math.sh index d5cf3880..9e099080 100644 --- a/src/util/math.sh +++ b/src/util/math.sh @@ -189,6 +189,12 @@ function bashunit::math::is_le() { return fi + # bc cannot parse a leading `+`: it answers with a parse error on stderr and an + # empty result, which reads as "greater than" here. The fixed-point path above + # handles the sign itself, so only the fallbacks need it stripped. + left=${left#+} + right=${right#+} + if bashunit::dependencies::has_bc; then [ "$(echo "$left <= $right" | bc)" = "1" ] return diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index eaa54f5f..a4274136 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -148,6 +148,26 @@ Reports an error if `actual` is not greater than or equal to `expected`. - assert_less_or_equal_than is the inverse of this assertion and takes the same arguments. +## assert_between +-------------- +> `assert_between "min" "max" "actual"` + +Reports an error if `actual` is outside the inclusive numeric range from `min` to `max`. +Integers, decimals, and negative values are supported. `min` must not be greater than `max`. + +- assert_not_between is the exact negation and takes the same arguments. + + +## assert_not_between +-------------- +> `assert_not_between "min" "max" "actual"` + +Reports an error if `actual` is inside the inclusive numeric range from `min` to `max`. +Integers, decimals, and negative values are supported. `min` must not be greater than `max`. + +- assert_between is the exact negation and takes the same arguments. + + ## assert_within_delta -------------- > `assert_within_delta "expected" "actual" "delta"` diff --git a/tests/unit/assert/arity_test.sh b/tests/unit/assert/arity_test.sh index 21aa71b1..8a49941f 100644 --- a/tests/unit/assert/arity_test.sh +++ b/tests/unit/assert/arity_test.sh @@ -43,6 +43,8 @@ function provide_core_assertions_requiring_arguments() { bashunit::data_set assert_less_or_equal_than 2 "expected, actual" bashunit::data_set assert_greater_than 2 "expected, actual" bashunit::data_set assert_greater_or_equal_than 2 "expected, actual" + bashunit::data_set assert_between 3 "min, max, actual" + bashunit::data_set assert_not_between 3 "min, max, actual" bashunit::data_set assert_within_delta 3 "expected, actual, delta" bashunit::data_set assert_line_count 2 "expected, actual" bashunit::data_set assert_string_matches_format 2 "format, actual" diff --git a/tests/unit/assert/numeric_test.sh b/tests/unit/assert/numeric_test.sh index 7b7c8e81..19c2cfef 100644 --- a/tests/unit/assert/numeric_test.sh +++ b/tests/unit/assert/numeric_test.sh @@ -163,6 +163,87 @@ function test_unsuccessful_assert_greater_or_equal_than() { "$(assert_greater_or_equal_than "3" "1")" } +function test_assert_between_accepts_values_inside_inclusive_integer_bounds() { + assert_between "1" "10" "5" + assert_between "1" "10" "1" + assert_between "1" "10" "10" +} + +function test_assert_between_supports_decimals_and_negative_numbers() { + assert_between "0.1" "0.3" "0.2" + assert_between "-10.5" "-1.5" "-3.25" + assert_between "+1" "+10" "+5" +} + +function test_assert_between_reports_the_violated_lower_bound() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Assert between reports the violated lower bound" "0" "to be between" "1 and 10" "Violated lower bound" "1")" \ + "$(assert_between "1" "10" "0")" +} + +function test_assert_between_reports_the_violated_upper_bound() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Assert between reports the violated upper bound" "11" "to be between" "1 and 10" "Violated upper bound" "10")" \ + "$(assert_between "1" "10" "11")" +} + +function test_range_assertions_count_as_exactly_one_assertion() { + local before=$_BASHUNIT_ASSERTIONS_PASSED + assert_between "1" "10" "5" + local after=$_BASHUNIT_ASSERTIONS_PASSED + + assert_same 1 "$((after - before))" + + before=$_BASHUNIT_ASSERTIONS_PASSED + assert_not_between "1" "10" "11" + after=$_BASHUNIT_ASSERTIONS_PASSED + + assert_same 1 "$((after - before))" +} + +function test_assert_not_between_is_the_exact_negation() { + assert_not_between "1" "10" "0" + assert_not_between "1" "10" "11" + assert_assertion_fails assert_not_between "1" "10" "1" + assert_assertion_fails assert_not_between "1" "10" "5" + assert_assertion_fails assert_not_between "1" "10" "10" +} + +# @data_provider provide_invalid_range_assertion_inputs +function test_range_assertions_reject_invalid_inputs_as_usage_errors() { + local assertion=$1 + local min=$2 + local max=$3 + local actual=$4 + local expected_detail=$5 + local output exit_code=0 + + output=$("$assertion" "$min" "$max" "$actual" 2>&1) || exit_code=$? + + assert_same 2 "$exit_code" + assert_same "bashunit: assertion usage error: $assertion $expected_detail" "$output" +} + +# Operands wider than the fork-free fixed-point path fall through to bc, which +# cannot parse the leading `+` that the assertions otherwise accept. +function test_range_assertions_accept_a_leading_plus_on_wide_operands() { + assert_empty "$(assert_between "+1" "+9999999999999999999999" "+5" 2>&1)" + assert_empty "$(assert_not_between "+1" "+10" "+9999999999999999999999" 2>&1)" +} + +function provide_invalid_range_assertion_inputs() { + bashunit::data_set assert_between abc 10 5 \ + "expects numeric min, max, and actual values, got 'abc', '10', '5'" + bashunit::data_set assert_between 1 1.2.3 1 \ + "expects numeric min, max, and actual values, got '1', '1.2.3', '1'" + bashunit::data_set assert_not_between 1 10 nope \ + "expects numeric min, max, and actual values, got '1', '10', 'nope'" + bashunit::data_set assert_between 10 1 5 "expects min <= max, got '10' and '1'" + bashunit::data_set assert_not_between 10 1 5 "expects min <= max, got '10' and '1'" +} + function test_successful_assert_within_delta() { assert_empty "$(assert_within_delta "3.14159" "3.14" "0.01")" } @@ -190,6 +271,24 @@ function test_unsuccessful_assert_within_delta_with_a_non_numeric_value() { "$(assert_within_delta "abc" "105" "3")" } +# A second dot and an inner sign both reached bc before: the first leaked a raw +# `Parse error` into the report, the second silently evaluated `5-3` as 2. +function test_assert_within_delta_rejects_a_second_decimal_point() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Assert within delta rejects a second decimal point" \ + "1.2.3 1 0.5" "to all be numeric" "but got a non-numeric value")" \ + "$(assert_within_delta "1.2.3" "1" "0.5" 2>&1)" +} + +function test_assert_within_delta_rejects_an_inner_sign() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Assert within delta rejects an inner sign" \ + "5-3 1 0.5" "to all be numeric" "but got a non-numeric value")" \ + "$(assert_within_delta "5-3" "1" "0.5" 2>&1)" +} + # bc cannot parse a leading `+`, but bashunit::assert::_is_numeric accepts one, # so this pair used to reach the comparison, get an empty result back, and fail # the assertion. The fixed-point path handles the sign itself. diff --git a/tests/unit/util/math_test.sh b/tests/unit/util/math_test.sh index 5d458c0f..f5081845 100644 --- a/tests/unit/util/math_test.sh +++ b/tests/unit/util/math_test.sh @@ -73,6 +73,25 @@ function test_calculate_fallback_to_bash_arithmetic_for_decimal() { assert_equals "30" "$result" } +# The fixed-point path refuses operands too wide for 64-bit integer arithmetic +# and hands them to bc, which cannot parse a leading `+` and answers with a parse +# error on stderr and an empty result -- read as "greater than" by the caller. +function test_is_le_handles_a_leading_plus_on_operands_wider_than_the_fixed_point_path() { + local stderr exit_code=0 + stderr=$(bashunit::math::is_le "+1" "+9999999999999999999999" 2>&1) || exit_code=$? + + assert_same 0 "$exit_code" + assert_empty "$stderr" +} + +function test_is_le_rejects_a_greater_left_operand_with_a_leading_plus() { + local stderr exit_code=0 + stderr=$(bashunit::math::is_le "+9999999999999999999999" "+1" 2>&1) || exit_code=$? + + assert_same 1 "$exit_code" + assert_empty "$stderr" +} + function test_shuffle_is_deterministic_for_a_given_seed() { local first second first=$(printf '%s\n' a b c d e f g h | bashunit::math::shuffle 12345)