diff --git a/CHANGELOG.md b/CHANGELOG.md index 70728ba8..b3fec0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Performance: a test in a file that defines `set_up` or `tear_down` is about 2.6x faster, which brings it level with a hookless test (14.1ms to 5.4ms per test on macOS arm64, bash 3.2). Each hook minted its output file with `mktemp` and removed it with `rm`, and the ownership marker that left behind made the runner `rm -rf` the test's temp files at exit — five forks per test, even for a test that created no temp file of its own (#1345) ### Fixed +- `assert_contains_ignore_case` documents what it actually guarantees: ASCII case folding. Whether `ñ` matches `Ñ` depends on the shell version, the locale and which `tr` the system ships — BSD `tr` folds it, GNU and busybox `tr` do not, and `nocasematch` only folds it in a UTF-8 locale. Behaviour is unchanged (#1351) - A test function whose name another selected name starts with is no longer rejected as a duplicate. `test_a` alongside `test_ab` read as one name twice, which made the whole file select nothing. Only definition order reached it, so nothing hit it before (#1347) - Coverage counts every line of a multiline statement: array literals, quoted strings, heredocs, backslash continuations. An array written one element per line cost one uncovered line per element. Bash 3.x records the assignment on its closing `)`, so there the whole array read as uncovered. A parent statement's hits no longer mark commands inside command or process substitutions as covered, even within quotes or arrays (#1338) - `--exclude-filter` keeps a comma as part of the test function name. The value was split at the comma, so `--exclude-filter 'test_a,{b}'` also excluded `test_a`. Repeat the flag to exclude several names. `BASHUNIT_EXCLUDE_FILTER` stays comma-separated, so one of its filters cannot hold a literal comma (#1340) diff --git a/docs/assertions.md b/docs/assertions.md index b9ff3228..e93e4fec 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -200,6 +200,8 @@ function test_failure() { Reports an error if `needle` is not a substring of `haystack`. Differences in casing are ignored when needle is searched for in haystack. +Case folding is guaranteed for ASCII only: whether `ñ` matches `Ñ` depends on +the shell version, the locale and which `tr` the system ships. ::: code-group ```bash [Example] diff --git a/src/assert/core.sh b/src/assert/core.sh index d175a72a..3f2c3951 100755 --- a/src/assert/core.sh +++ b/src/assert/core.sh @@ -462,10 +462,22 @@ function assert_contains_ignore_case() { # nocasematch (Bash 3.1+) folds case inside the `case` itself, which costs no # fork at all; the two `tr` pipelines below cost two. Measured on Bash 3.2: - # 0.087ms per call versus 12.8ms. Both fold non-ASCII identically in a UTF-8 - # locale -- `ñü` matches `ÑÜ` either way -- which rules out the tempting - # pure-bash A-Z loop, since that is ASCII-only and would silently stop - # matching accented text that matches today. + # 0.087ms per call versus 12.8ms. + # + # Only ASCII folding is promised, because only ASCII folding is portable. + # Measured on `ÑÜ`: + # + # BSD tr (macOS) folds -> ñü + # GNU tr (Debian) does not -> ÑÜ + # busybox tr does not -> ÑÜ + # nocasematch, UTF-8 folds -> matches + # nocasematch, LC_ALL=C does not -> no match + # + # So the two branches disagree on non-ASCII wherever `tr` is not BSD, and the + # fast path alone disagrees with itself across locales. The Bash 3.0 job runs + # on Debian, so that is CI, not a hypothetical. This comment used to claim the + # two agreed on non-ASCII in a UTF-8 locale, and used that to rule out a + # pure-bash A-Z fold for the fallback; the claim was wrong (#1351). # # Prior state is saved and restored rather than blindly unset: nocasematch is # a global shell option and a user's test file may already have set it. `shopt @@ -492,7 +504,8 @@ function assert_contains_ignore_case() { return fi - # Bash 3.0 only: nocasematch does not exist, so fold with tr. + # Bash 3.0 only: nocasematch does not exist, so fold with tr. ASCII-only in + # practice on GNU and busybox tr; see the note above. local expected_lower local actual_lower expected_lower=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') 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 adaacac3..6504b02f 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 @@ -53,6 +53,8 @@ Reports an error if `needle` is not a substring of `haystack`. Reports an error if `needle` is not a substring of `haystack`. Differences in casing are ignored when needle is searched for in haystack. +Case folding is guaranteed for ASCII only: whether `ñ` matches `Ñ` depends on +the shell version, the locale and which `tr` the system ships. ## assert_empty diff --git a/tests/unit/assert/string_test.sh b/tests/unit/assert/string_test.sh index d2b94298..03f816af 100644 --- a/tests/unit/assert/string_test.sh +++ b/tests/unit/assert/string_test.sh @@ -53,6 +53,19 @@ function test_successful_assert_contains_ignore_case() { assert_empty "$(assert_contains_ignore_case "Linux" "GNU/LINUX")" } +# ASCII case folding is the whole of what this assertion promises, and it has +# two implementations -- nocasematch from Bash 3.1, two `tr` pipelines below +# that. This runs on both: the Bash 3.0 job takes the `tr` branch, every other +# job the fast one. Non-ASCII is deliberately not asserted, because BSD `tr` +# folds it, GNU and busybox `tr` do not, and nocasematch itself only folds it +# in a UTF-8 locale (#1351). +function test_assert_contains_ignore_case_folds_ascii_on_either_implementation() { + assert_empty "$(assert_contains_ignore_case "linux" "GNU/LINUX")" + assert_empty "$(assert_contains_ignore_case "LINUX" "gnu/linux")" + assert_empty "$(assert_contains_ignore_case "LiNuX" "GnU/lInUx")" + assert_empty "$(assert_contains_ignore_case "0-9_." "0-9_.")" +} + function test_unsuccessful_assert_contains_ignore_case() { local expected expected="$(bashunit::console_results::print_failed_test \