diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fbf443a..51bc2e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## Unreleased ### Added -- Add `assert_have_been_called_nth_with` assertion for verifying arguments on the Nth invocation of a spy (Issue #172) +- Add `assert_have_been_called_nth_with` for verifying arguments on the Nth invocation of a spy +- Add `assert_string_matches_format` and `assert_string_not_matches_format` with format placeholders (`%d`, `%s`, `%f`, `%i`, `%x`, `%e`, `%%`) ### Changed - Split Windows CI test jobs into parallel chunks to avoid timeouts diff --git a/docs/assertions.md b/docs/assertions.md index 6631ab32..1eb9fdab 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -221,6 +221,36 @@ function test_failure() { ``` ::: +## assert_string_matches_format +> `assert_string_matches_format "format" "value"` + +Reports an error if `value` does not match the `format` string. The format string uses PHPUnit-style placeholders: + +| Placeholder | Matches | +|-------------|---------| +| `%d` | One or more digits | +| `%i` | Signed integer (e.g. `+1`, `-42`) | +| `%f` | Floating point number (e.g. `3.14`) | +| `%s` | One or more non-whitespace characters | +| `%x` | Hexadecimal (e.g. `ff00ab`) | +| `%e` | Scientific notation (e.g. `1.5e10`) | +| `%%` | Literal `%` character | + +- [assert_string_not_matches_format](#assert-string-not-matches-format) is the inverse of this assertion and takes the same arguments. + +::: code-group +```bash [Example] +function test_success() { + assert_string_matches_format "%d items found" "42 items found" + assert_string_matches_format "%s has %d items at %f each" "cart has 5 items at 9.99 each" +} + +function test_failure() { + assert_string_matches_format "%d items" "hello world" +} +``` +::: + ## assert_line_count > `assert_line_count "count" "haystack"` @@ -957,6 +987,25 @@ function test_failure() { ``` ::: +## assert_string_not_matches_format +> `assert_string_not_matches_format "format" "value"` + +Reports an error if `value` matches the `format` string. See [assert_string_matches_format](#assert-string-matches-format) for supported placeholders. + +- [assert_string_matches_format](#assert-string-matches-format) is the inverse of this assertion and takes the same arguments. + +::: code-group +```bash [Example] +function test_success() { + assert_string_not_matches_format "%d items" "hello world" +} + +function test_failure() { + assert_string_not_matches_format "%d items" "42 items" +} +``` +::: + ## assert_array_not_contains > `assert_array_not_contains "needle" "haystack"` diff --git a/docs/test-doubles.md b/docs/test-doubles.md index a288b9f3..d1ded5ce 100644 --- a/docs/test-doubles.md +++ b/docs/test-doubles.md @@ -174,6 +174,35 @@ function test_failure() { ::: +## assert_have_been_called_nth_with +> `assert_have_been_called_nth_with "nth" "spy" "expected"` + +Reports an error if the `nth` invocation of `spy` was not called with `expected`. The index starts at 1. Reports an error if `spy` was called fewer than `nth` times. + +::: code-group +```bash [Example] +function test_success() { + bashunit::spy ps + + ps first + ps second + ps third + + assert_have_been_called_nth_with 1 ps "first" + assert_have_been_called_nth_with 2 ps "second" + assert_have_been_called_nth_with 3 ps "third" +} + +function test_failure() { + bashunit::spy ps + + ps first + + assert_have_been_called_nth_with 1 ps "wrong" +} +``` +::: + ## assert_have_been_called_times > assert_have_been_called_times "expected" "spy" diff --git a/src/assert.sh b/src/assert.sh index c6e23bfa..14a332da 100755 --- a/src/assert.sh +++ b/src/assert.sh @@ -729,3 +729,89 @@ function assert_line_count() { bashunit::state::add_assertions_passed } + +function bashunit::format_to_regex() { + local format="$1" + local regex="" + local i=0 + local len=${#format} + + while [ $i -lt "$len" ]; do + local char="${format:$i:1}" + if [ "$char" = "%" ] && [ $((i + 1)) -lt "$len" ]; then + local next="${format:$((i + 1)):1}" + case "$next" in + d) regex="${regex}[0-9]+" ;; + i) regex="${regex}[+-]?[0-9]+" ;; + f) regex="${regex}[+-]?[0-9]*\\.?[0-9]+" ;; + s) regex="${regex}[^ ]+" ;; + x) regex="${regex}[0-9a-fA-F]+" ;; + e) regex="${regex}[+-]?[0-9]*\\.?[0-9]+[eE][+-]?[0-9]+" ;; + %) regex="${regex}%" ;; + *) + regex="${regex}%${next}" + ;; + esac + i=$((i + 2)) + else + case "$char" in + . | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$') + regex="${regex}\\${char}" + ;; + \\) + regex="${regex}\\\\" + ;; + *) + regex="${regex}${char}" + ;; + esac + i=$((i + 1)) + fi + done + + printf '%s' "^${regex}$" +} + +function assert_string_matches_format() { + bashunit::assert::should_skip && return 0 + + local format="$1" + local actual="$2" + + local regex + regex="$(bashunit::format_to_regex "$format")" + + if ! [[ "$actual" =~ $regex ]]; then + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test "${label}" "${actual}" "to match format" "${format}" + return + fi + + bashunit::state::add_assertions_passed +} + +function assert_string_not_matches_format() { + bashunit::assert::should_skip && return 0 + + local format="$1" + local actual="$2" + + local regex + regex="$(bashunit::format_to_regex "$format")" + + if [[ "$actual" =~ $regex ]]; then + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test "${label}" "${actual}" "to not match format" "${format}" + return + fi + + bashunit::state::add_assertions_passed +} diff --git a/tests/unit/assert_string_test.sh b/tests/unit/assert_string_test.sh index 004e16b0..8deec2a0 100644 --- a/tests/unit/assert_string_test.sh +++ b/tests/unit/assert_string_test.sh @@ -158,3 +158,55 @@ function test_assert_string_start_end_with_special_chars_fail() { "Assert string start end with special chars fail" "fooX" "to end with" ".bar")" \ "$(assert_string_ends_with ".bar" "fooX")" } + +function test_successful_assert_string_matches_format_with_digit() { + assert_empty "$(assert_string_matches_format "%d items found" "42 items found")" +} + +function test_successful_assert_string_matches_format_with_string() { + assert_empty "$(assert_string_matches_format "Hello %s" "Hello world")" +} + +function test_successful_assert_string_matches_format_with_hex() { + assert_empty "$(assert_string_matches_format "Color: %x" "Color: ff00ab")" +} + +function test_successful_assert_string_matches_format_with_float() { + assert_empty "$(assert_string_matches_format "Value: %f" "Value: 3.14")" +} + +function test_successful_assert_string_matches_format_with_signed_integer() { + assert_empty "$(assert_string_matches_format "Offset: %i" "Offset: -42")" +} + +function test_successful_assert_string_matches_format_with_scientific() { + assert_empty "$(assert_string_matches_format "Result: %e" "Result: 1.5e10")" +} + +function test_successful_assert_string_matches_format_with_literal_percent() { + assert_empty "$(assert_string_matches_format "100%% done" "100% done")" +} + +function test_successful_assert_string_matches_format_with_multiple_placeholders() { + assert_empty "$(assert_string_matches_format "%s has %d items at %f each" "cart has 5 items at 9.99 each")" +} + +function test_unsuccessful_assert_string_matches_format() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert string matches format" \ + "hello world" "to match format" "%d items")" \ + "$(assert_string_matches_format "%d items" "hello world")" +} + +function test_successful_assert_string_not_matches_format() { + assert_empty "$(assert_string_not_matches_format "%d items" "hello world")" +} + +function test_unsuccessful_assert_string_not_matches_format() { + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert string not matches format" \ + "42 items" "to not match format" "%d items")" \ + "$(assert_string_not_matches_format "%d items" "42 items")" +}