From 6dfe660afeaaac2da2092a960e5058fc18baffbf Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sat, 24 Oct 2026 13:26:20 +0200 Subject: [PATCH] perf(runner): index large metadata maps Keep small maps on direct scans to avoid index setup cost. Use the same unexported index on Bash 3.0 and newer. Closes #1392 --- CHANGELOG.md | 4 + ...dr-011-source-layout-and-build-pipeline.md | 4 +- adrs/adr-014-metadata-lookup-index.md | 33 +++ src/helper/annotations.sh | 40 ++-- src/helper/index.sh | 4 +- src/helper/metadata.sh | 58 +++++ src/helper/provider.sh | 38 +++- tests/acceptance/bashunit_annotations_test.sh | 44 ++++ tests/unit/helper/annotations_test.sh | 199 ++++++++++++++++++ 9 files changed, 395 insertions(+), 29 deletions(-) create mode 100644 adrs/adr-014-metadata-lookup-index.md create mode 100644 src/helper/metadata.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c55cd31..a45850d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Blog posts for releases 0.43.0 through 0.52.0, including the 0.50.1 cleanup patch +### Changed + +- Large provider and annotation maps use an index instead of scanning earlier entries, speeding up test files on Bash 3.0 and newer (#1392) + ## [0.52.0](https://github.com/TypedDevs/bashunit/compare/0.51.0...0.52.0) - 2026-10-11 ### Changed diff --git a/adrs/adr-011-source-layout-and-build-pipeline.md b/adrs/adr-011-source-layout-and-build-pipeline.md index 3aec0559..e5386138 100644 --- a/adrs/adr-011-source-layout-and-build-pipeline.md +++ b/adrs/adr-011-source-layout-and-build-pipeline.md @@ -56,7 +56,7 @@ Sixteen, in load order. The order is the dependency layering: leaves first. | 6 | `coverage/` | 16 | 3277 | line/branch tracking, diff coverage and the five report formats | | 7 | `state/` | 6 | 474 | counters, per-test context, result payload, parallel aggregation | | 8 | `console/` | 9 | 1278 | everything printed: palette, header, per-test lines, totals | -| 9 | `helper/` | 9 | 1071 | naming, discovery, data providers, tags, per-test annotations, encoding | +| 9 | `helper/` | 10 | 1789 | naming, discovery, data providers, tags, per-test annotations, encoding | | 10 | `cli/` | 5 | 445 | the `doc`/`init`/`upgrade`/`watch` subcommand implementations | | 11 | `assert/` | 11 | 2326 | every assertion | | 12 | `doubles/` | 4 | 505 | spies and mocks — **sourced by `assert/index.sh`, not the entrypoint** | @@ -65,7 +65,7 @@ Sixteen, in load order. The order is the dependency layering: leaves first. | 15 | `benchmark/` | 6 | 649 | the bench implementation (`runner/bench.sh` is its loop) | | 16 | `main/` | 8 | 1473 | flag parsing per subcommand and the run lifecycle | -The file counts sum to 114, which is every `.sh` file in `src/` — that is the check to re-run +The file counts sum to 115, which is every `.sh` file in `src/`. Re-run this check when editing this table, because both of its previous errors came from counting the wrong way. `doubles/` was absent entirely: the table was generated from the entrypoint's `source` lines, and `doubles/` is the one module the entrypoint does not source, so it fell through while the diff --git a/adrs/adr-014-metadata-lookup-index.md b/adrs/adr-014-metadata-lookup-index.md new file mode 100644 index 00000000..34c9f39a --- /dev/null +++ b/adrs/adr-014-metadata-lookup-index.md @@ -0,0 +1,33 @@ +# Index provider and annotation metadata with shell variables + +* Status: accepted +* Date: 2026-10-24 + +Related: https://github.com/TypedDevs/bashunit/issues/1392 + +## Context and Problem Statement + +The provider scanner already reads a test file once. Its consumers still scan arrays of function names for every provider or annotation lookup. Resolving annotations in the runner and again inside each attempt repeats that work. A file with many annotated tests therefore spends increasing time finding metadata for later tests. + +## Decision Outcome + +Keep the arrays as the source of metadata values. Scan maps with up to 64 entries directly and build an index from function name to array position for larger maps. A 100-pair comparison found about 2 to 3ms of setup overhead for a 10-test annotated file when every map was indexed; avoiding that fixed cost keeps small files on their existing path. Use ordinary, unexported shell variables for index entries and Bash's indirect expansion to read them. This works on Bash 3.0 and newer, without an external command or a second implementation. + +Index only the name domain emitted by the existing scanner: ASCII letters, digits, underscores and colons. A lookup outside that domain returns no entry. Escape underscores as `__` before escaping colons as `_c`, so different function names cannot produce the same variable name. Separate prefixes distinguish provider and annotation entries. + +Record the first position for each name, preserving the previous scan's first-match behavior. Clear entries using the previous map's names before resetting its arrays. Build the index once after scanner output is loaded when the map exceeds the size limit. Small files, including the validated header-cache path, keep direct lookup. Recording another annotation invalidates its index flag so direct helper calls continue to see appended entries. The index has the same lifetime as its map and is inherited by test workers. + +Use builtin `read` to assign an index variable by name. Its input is a small integer position. No index entry is exported and no generated shell text is evaluated. Missing entries use the existing empty/default return slots. If index construction fails, leave its flag off and use the arrays directly; the next reset removes any partial index entries. + +## Alternatives + +* Continue scanning all arrays: simplest storage, but large maps repeatedly walk earlier entries. Keep this path only for small maps. +* Cache only the last lookup: helps repeated data sets and attempts, but distinct tests still scan the map. +* Use associative arrays on Bash 4+: introduces a second implementation when native shell-variable lookup already handles the scanner's key domain on Bash 3.0. +* Search a serialized string table: an experiment was slower than the original loop on Bash 3.2 and was rejected. + +## Consequences + +The index adds one private shell variable per distinct provider or annotation name. Reset must remove these variables as well as clear the arrays. Changes to the scanner's name domain must update the key encoding and its identity tests. + +A trace-budget regression checks that late and absent lookups execute a bounded number of shell commands. Behavioral tests cover identity, resets, first-match behavior, and execution across providers, annotations, filtering, random order and parallel workers. End-to-end timings measure the full run separately from lookup-only experiments. diff --git a/src/helper/annotations.sh b/src/helper/annotations.sh index 357fc5fb..d85a5016 100644 --- a/src/helper/annotations.sh +++ b/src/helper/annotations.sh @@ -22,6 +22,9 @@ _BASHUNIT_ANNOT_REASON_OUT="" # Empties the map. Called whenever the provider pass starts a new file. ## function bashunit::helper::annotations_reset() { + bashunit::helper::metadata_index_reset ANNOT \ + "${_BASHUNIT_ANNOT_MAP_FNS[@]+"${_BASHUNIT_ANNOT_MAP_FNS[@]}"}" + _BASHUNIT_ANNOT_MAP_INDEXED=false _BASHUNIT_ANNOT_MAP_FNS=() _BASHUNIT_ANNOT_MAP_TIMEOUTS=() _BASHUNIT_ANNOT_MAP_RETRIES=() @@ -36,6 +39,7 @@ function bashunit::helper::annotations_reset() { ## function bashunit::helper::annotations_record() { local count=${#_BASHUNIT_ANNOT_MAP_FNS[@]} + _BASHUNIT_ANNOT_MAP_INDEXED=false _BASHUNIT_ANNOT_MAP_FNS[count]="$1" _BASHUNIT_ANNOT_MAP_TIMEOUTS[count]="${2-}" _BASHUNIT_ANNOT_MAP_RETRIES[count]="${3-}" @@ -50,27 +54,31 @@ function bashunit::helper::annotations_record() { # Arguments: $1 - test-function name ## function bashunit::helper::annotations_for_function() { - local function_name=$1 - local i=0 + local function_name=$1 index=0 local total=${#_BASHUNIT_ANNOT_MAP_FNS[@]} - _BASHUNIT_ANNOT_TIMEOUT_OUT="" _BASHUNIT_ANNOT_RETRY_OUT="" _BASHUNIT_ANNOT_SKIP_OUT="false" _BASHUNIT_ANNOT_REASON_OUT="" - - while [ "$i" -lt "$total" ]; do - if [ "${_BASHUNIT_ANNOT_MAP_FNS[i]}" = "$function_name" ]; then - _BASHUNIT_ANNOT_TIMEOUT_OUT="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[i]}" - _BASHUNIT_ANNOT_RETRY_OUT="${_BASHUNIT_ANNOT_MAP_RETRIES[i]}" - if [ "${_BASHUNIT_ANNOT_MAP_SKIPS[i]}" = "1" ]; then - _BASHUNIT_ANNOT_SKIP_OUT="true" - fi - _BASHUNIT_ANNOT_REASON_OUT="${_BASHUNIT_ANNOT_MAP_REASONS[i]}" - return - fi - i=$((i + 1)) - done + [ "$total" -gt 0 ] || return 0 + + if [ "$_BASHUNIT_ANNOT_MAP_INDEXED" = true ]; then + bashunit::helper::metadata_index_lookup ANNOT "$function_name" + index=$_BASHUNIT_METADATA_INDEX_OUT + [ -n "$index" ] || return 0 + else + while [ "$index" -lt "$total" ]; do + [ "${_BASHUNIT_ANNOT_MAP_FNS[index]}" != "$function_name" ] || break + index=$((index + 1)) + done + [ "$index" -lt "$total" ] || return 0 + fi + _BASHUNIT_ANNOT_TIMEOUT_OUT="${_BASHUNIT_ANNOT_MAP_TIMEOUTS[index]}" + _BASHUNIT_ANNOT_RETRY_OUT="${_BASHUNIT_ANNOT_MAP_RETRIES[index]}" + if [ "${_BASHUNIT_ANNOT_MAP_SKIPS[index]}" = "1" ]; then + _BASHUNIT_ANNOT_SKIP_OUT="true" + fi + _BASHUNIT_ANNOT_REASON_OUT="${_BASHUNIT_ANNOT_MAP_REASONS[index]}" } ## diff --git a/src/helper/index.sh b/src/helper/index.sh index fab986b7..23f48264 100644 --- a/src/helper/index.sh +++ b/src/helper/index.sh @@ -3,12 +3,12 @@ # Entry point for the src/helper/ module. `source` lines and comments only, # for the reason recorded in adrs/adr-011-source-layout-and-build-pipeline.md. # -# Sourced leaves first; only discovery.sh has cross-file callees (provider.sh -# and functions.sh). +# Source metadata indexing before the maps that use it, then discovery. source "$BASHUNIT_ROOT_DIR/src/helper/naming.sh" source "$BASHUNIT_ROOT_DIR/src/helper/encoding.sh" source "$BASHUNIT_ROOT_DIR/src/helper/functions.sh" source "$BASHUNIT_ROOT_DIR/src/helper/git.sh" +source "$BASHUNIT_ROOT_DIR/src/helper/metadata.sh" source "$BASHUNIT_ROOT_DIR/src/helper/annotations.sh" source "$BASHUNIT_ROOT_DIR/src/helper/provider.sh" source "$BASHUNIT_ROOT_DIR/src/helper/tags.sh" diff --git a/src/helper/metadata.sh b/src/helper/metadata.sh new file mode 100644 index 00000000..59ecd946 --- /dev/null +++ b/src/helper/metadata.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +_BASHUNIT_METADATA_KEY_OUT="" +_BASHUNIT_METADATA_INDEX_OUT="" +_BASHUNIT_METADATA_INDEX_LIMIT=64 +_BASHUNIT_PROVIDER_MAP_INDEXED=false +_BASHUNIT_ANNOT_MAP_INDEXED=false + +function bashunit::helper::metadata_key() { + _BASHUNIT_METADATA_KEY_OUT="" + case "$2" in + '' | *[!a-zA-Z0-9_:]*) return ;; + esac + local escaped="${2//_/__}" + # Escape underscores first so a colon cannot collide with a literal _c. + _BASHUNIT_METADATA_KEY_OUT="_BASHUNIT_METADATA_${1}_${escaped//:/_c}" +} + +function bashunit::helper::metadata_index_reset() { + local group=$1 name + shift + [ "$#" -gt "$_BASHUNIT_METADATA_INDEX_LIMIT" ] || return 0 + for name in "$@"; do + bashunit::helper::metadata_key "$group" "$name" + [ -z "$_BASHUNIT_METADATA_KEY_OUT" ] || unset "$_BASHUNIT_METADATA_KEY_OUT" + done +} + +function bashunit::helper::metadata_index_record() { + bashunit::helper::metadata_key "$1" "$2" + [ -n "$_BASHUNIT_METADATA_KEY_OUT" ] || return 0 + local key=$_BASHUNIT_METADATA_KEY_OUT + [ -z "${!key-}" ] || return 0 + if ! IFS= read -r "${key?}" <<<"$3"; then + unset -v "${key?}" + return 1 + fi + export -n "${key?}" +} + +function bashunit::helper::metadata_index_lookup() { + _BASHUNIT_METADATA_INDEX_OUT="" + bashunit::helper::metadata_key "$1" "$2" + [ -n "$_BASHUNIT_METADATA_KEY_OUT" ] || return 0 + local key=$_BASHUNIT_METADATA_KEY_OUT + _BASHUNIT_METADATA_INDEX_OUT="${!key-}" +} + +function bashunit::helper::metadata_index_build() { + local group=$1 name index=0 + shift + [ "$#" -gt "$_BASHUNIT_METADATA_INDEX_LIMIT" ] || return 1 + for name in "$@"; do + bashunit::helper::metadata_index_record "$group" "$name" "$index" || return 1 + index=$((index + 1)) + done + return 0 +} diff --git a/src/helper/provider.sh b/src/helper/provider.sh index 345e892d..fae89091 100644 --- a/src/helper/provider.sh +++ b/src/helper/provider.sh @@ -48,6 +48,9 @@ function bashunit::helper::build_provider_map() { # Unreadable path: reset to an empty map keyed to this argument so a # follow-up lookup returns empty without rescanning. _BASHUNIT_PROVIDER_MAP_SCRIPT="$1" + bashunit::helper::metadata_index_reset PROVIDER \ + "${_BASHUNIT_PROVIDER_MAP_FNS[@]+"${_BASHUNIT_PROVIDER_MAP_FNS[@]}"}" + _BASHUNIT_PROVIDER_MAP_INDEXED=false _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false @@ -92,6 +95,9 @@ function bashunit::helper::build_provider_map() { fi _BASHUNIT_PROVIDER_MAP_SCRIPT="$script" + bashunit::helper::metadata_index_reset PROVIDER \ + "${_BASHUNIT_PROVIDER_MAP_FNS[@]+"${_BASHUNIT_PROVIDER_MAP_FNS[@]}"}" + _BASHUNIT_PROVIDER_MAP_INDEXED=false _BASHUNIT_PROVIDER_MAP_FNS=() _BASHUNIT_PROVIDER_MAP_PROVIDERS=() _BASHUNIT_PROVIDER_MAP_NO_PARALLEL=false @@ -266,6 +272,15 @@ function bashunit::helper::build_provider_map() { _BASHUNIT_PROVIDER_MAP_PROVIDERS[count]="$provider" count=$((count + 1)) done <<<"$scan_rows" + + if bashunit::helper::metadata_index_build PROVIDER \ + "${_BASHUNIT_PROVIDER_MAP_FNS[@]+"${_BASHUNIT_PROVIDER_MAP_FNS[@]}"}"; then + _BASHUNIT_PROVIDER_MAP_INDEXED=true + fi + if bashunit::helper::metadata_index_build ANNOT \ + "${_BASHUNIT_ANNOT_MAP_FNS[@]+"${_BASHUNIT_ANNOT_MAP_FNS[@]}"}"; then + _BASHUNIT_ANNOT_MAP_INDEXED=true + fi } @@ -275,17 +290,22 @@ function bashunit::helper::build_provider_map() { # Arguments: $1 - test-function name # function bashunit::helper::provider_for_function() { - local function_name=$1 - local i=0 + local function_name=$1 index=0 local total=${#_BASHUNIT_PROVIDER_MAP_FNS[@]} - while [ "$i" -lt "$total" ]; do - if [ "${_BASHUNIT_PROVIDER_MAP_FNS[i]}" = "$function_name" ]; then - _BASHUNIT_PROVIDER_FN_OUT="${_BASHUNIT_PROVIDER_MAP_PROVIDERS[i]}" - return - fi - i=$((i + 1)) - done _BASHUNIT_PROVIDER_FN_OUT="" + [ "$total" -gt 0 ] || return 0 + if [ "$_BASHUNIT_PROVIDER_MAP_INDEXED" = true ]; then + bashunit::helper::metadata_index_lookup PROVIDER "$function_name" + index=$_BASHUNIT_METADATA_INDEX_OUT + [ -n "$index" ] || return 0 + else + while [ "$index" -lt "$total" ]; do + [ "${_BASHUNIT_PROVIDER_MAP_FNS[index]}" != "$function_name" ] || break + index=$((index + 1)) + done + [ "$index" -lt "$total" ] || return 0 + fi + _BASHUNIT_PROVIDER_FN_OUT="${_BASHUNIT_PROVIDER_MAP_PROVIDERS[index]}" } diff --git a/tests/acceptance/bashunit_annotations_test.sh b/tests/acceptance/bashunit_annotations_test.sh index e0ad0295..8f3b7127 100644 --- a/tests/acceptance/bashunit_annotations_test.sh +++ b/tests/acceptance/bashunit_annotations_test.sh @@ -233,3 +233,47 @@ function test_a_malformed_annotation_is_counted_once_under_parallel() { assert_contains 'name="Bad annotation beside a good file"' "$junit" assert_contains "is not a non-negative integer" "$junit" } + +function test_metadata_stays_with_its_test_across_files_filters_and_random_order_sequential() { + run_indexed_metadata_fixture --no-parallel +} + +function test_metadata_stays_with_its_test_across_files_filters_and_random_order_parallel() { + run_indexed_metadata_fixture --parallel +} + +function run_indexed_metadata_fixture() { + local mode=$1 dir output status=0 i + dir="$(bashunit::temp_dir)" + printf '%s\n' \ + 'function indexed_rows() { printf "%s\n" alpha beta; }' \ + '# @timeout 0' '# @retry 1' '# @data_provider indexed_rows' \ + 'function test_indexed_rows() {' \ + ' local marker="$METADATA_MARKERS/$1"' \ + ' printf "%s\n" attempt >>"$marker"' \ + ' assert_same 2 "$(wc -l <"$marker" | tr -d " ")"' \ + '}' \ + '# @skip metadata skip' \ + 'function test_indexed_skip() { printf "%s\n" "skip ran" >"$METADATA_MARKERS/unexpected"; }' \ + >"$dir/first_test.sh" + for ((i = 0; i < 65; i++)); do + printf '# @timeout 0\n# @data_provider indexed_rows\nfunction metadata_padding_%s() { :; }\n' "$i" + done >>"$dir/first_test.sh" + printf '%s\n' \ + 'function test_indexed_plain() { assert_same plain plain; }' \ + '# @skip must not carry into a selected test' \ + 'function test_indexed_excluded() { assert_same excluded selected; }' \ + >"$dir/second_test.sh" + + output=$(METADATA_MARKERS="$dir" ./bashunit "$mode" --env "$TEST_ENV_FILE" \ + --random-order --seed 42 --filter indexed --exclude-filter indexed_excluded \ + "$dir/first_test.sh" "$dir/second_test.sh" 2>&1) || status=$? + + [ "$status" -eq 0 ] || printf '%s\n' "$output" + assert_same 0 "$status" + assert_contains '3 passed' "$output" + assert_contains '1 skipped' "$output" + assert_same '2' "$(wc -l <"$dir/alpha" | tr -d ' ')" + assert_same '2' "$(wc -l <"$dir/beta" | tr -d ' ')" + assert_file_not_exists "$dir/unexpected" +} diff --git a/tests/unit/helper/annotations_test.sh b/tests/unit/helper/annotations_test.sh index a3727f17..6ab549ed 100644 --- a/tests/unit/helper/annotations_test.sh +++ b/tests/unit/helper/annotations_test.sh @@ -16,6 +16,15 @@ function build_map() { # $1 = file content bashunit::helper::build_provider_map "$FIXTURE" } +function build_indexed_map() { + local i + printf '%s\n' "$1" >"$FIXTURE" + for ((i = 0; i < 65; i++)); do + printf '# @timeout 0\n# @data_provider rows\nfunction metadata_padding_%s() { :; }\n' "$i" + done >>"$FIXTURE" + bashunit::helper::build_provider_map "$FIXTURE" +} + function test_annotations_are_read_from_the_block_above_the_function() { build_map "# @timeout 30 # @retry 2 @@ -101,3 +110,193 @@ ${FN}() { :; }" assert_same "0" "$_BASHUNIT_ANNOT_TIMEOUT_OUT" } + +function test_metadata_lookup_work_is_bounded_for_late_and_missing_functions() { + local i name trace lines + for ((i = 0; i < 100; i++)); do + printf '# @timeout 0\n# @data_provider rows\nfunction test_index_%s() { :; }\n' "$i" + done >"$FIXTURE" + bashunit::helper::build_provider_map "$FIXTURE" + trace="$(bashunit::temp_file)" + + for name in test_index_99 test_index_absent; do + ( + trap - DEBUG + unset BASH_XTRACEFD + PS4='+' + set -x + bashunit::helper::provider_for_function "$name" + bashunit::helper::annotations_for_function "$name" + set +x + ) 2>"$trace" + lines=$(wc -l <"$trace") + + assert_less_than 100 "$lines" + done +} + +function test_metadata_indexes_distinguish_colons_from_underscores() { + build_indexed_map '# @timeout 7 +# @data_provider rows_colons +function test_index::name() { :; } +# @timeout 9 +# @data_provider rows_underscores +function test_index_c_cname() { :; }' + + bashunit::helper::provider_for_function 'test_index::name' + bashunit::helper::annotations_for_function 'test_index::name' + assert_same 'rows_colons' "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same '7' "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + + bashunit::helper::provider_for_function test_index_c_cname + bashunit::helper::annotations_for_function test_index_c_cname + assert_same 'rows_underscores' "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same '9' "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + + bashunit::helper::provider_for_function 'test_index*' + bashunit::helper::annotations_for_function 'test_index*' + assert_empty "$_BASHUNIT_PROVIDER_FN_OUT" + assert_empty "$_BASHUNIT_ANNOT_TIMEOUT_OUT" +} + +function test_metadata_indexes_clear_when_a_file_is_missing_or_rebuilt() { + build_indexed_map "# @timeout 3 +# @data_provider rows +${FN}() { :; }" + bashunit::helper::build_provider_map "$FIXTURE.missing" + + bashunit::helper::provider_for_function "$FN" + bashunit::helper::annotations_for_function "$FN" + assert_empty "$_BASHUNIT_PROVIDER_FN_OUT" + assert_empty "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + + build_map "${FN}() { :; }" + bashunit::helper::provider_for_function "$FN" + bashunit::helper::annotations_for_function "$FN" + assert_empty "$_BASHUNIT_PROVIDER_FN_OUT" + assert_empty "$_BASHUNIT_ANNOT_TIMEOUT_OUT" +} + +function test_metadata_indexes_keep_the_first_definition() { + build_indexed_map "# @timeout 3 +# @data_provider first_rows +${FN}() { :; } +# @timeout 7 +# @data_provider second_rows +${FN}() { :; }" + + bashunit::helper::provider_for_function "$FN" + bashunit::helper::annotations_for_function "$FN" + + assert_same 'first_rows' "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same '3' "$_BASHUNIT_ANNOT_TIMEOUT_OUT" +} + +function test_metadata_indexes_stay_unexported_when_allexport_is_enabled() { + local exported + exported=$( + set -a + build_indexed_map "# @timeout 3 +# @data_provider rows +${FN}() { :; }" + export -p | grep -E '^declare -x _BASHUNIT_METADATA_(PROVIDER|ANNOT)_' || true + ) + + assert_empty "$exported" +} + +function test_small_metadata_maps_avoid_per_function_index_allocation() { + build_map "# @timeout 3 +# @data_provider rows +${FN}() { :; }" + + assert_empty "$(compgen -A variable _BASHUNIT_METADATA_ANNOT_)" + assert_empty "$(compgen -A variable _BASHUNIT_METADATA_PROVIDER_)" + bashunit::helper::provider_for_function "$FN" + bashunit::helper::annotations_for_function "$FN" + assert_same rows "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same 3 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" +} + +function test_annotations_recorded_after_indexing_remain_visible() { + build_indexed_map "# @timeout 3 +${FN}() { :; }" + + bashunit::helper::annotations_record "$OTHER_FN" 9 2 1 'added later' + bashunit::helper::annotations_for_function "$OTHER_FN" + assert_same 9 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + assert_same 2 "$_BASHUNIT_ANNOT_RETRY_OUT" + assert_same true "$_BASHUNIT_ANNOT_SKIP_OUT" + assert_same 'added later' "$_BASHUNIT_ANNOT_REASON_OUT" + bashunit::helper::annotations_for_function "$FN" + assert_same 3 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" +} + +function test_large_metadata_indexes_rebuild_positions_between_files() { + build_indexed_map "# @timeout 3 +# @data_provider first_rows +${FN}() { :; }" + FIXTURE="$(bashunit::temp_file)" + + build_indexed_map "# @timeout 1 +# @data_provider other_rows +${OTHER_FN}() { :; } +# @timeout 9 +# @data_provider second_rows +${FN}() { :; }" + bashunit::helper::provider_for_function "$FN" + bashunit::helper::annotations_for_function "$FN" + + assert_same second_rows "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same 9 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" +} + +function test_metadata_values_survive_the_index_size_boundary() { + local size i + for size in 64 65; do + FIXTURE="$(bashunit::temp_file)" + for ((i = 0; i < size; i++)); do + printf '# @timeout %s\n# @data_provider rows_%s\nfunction metadata_boundary_%s() { :; }\n' "$i" "$i" "$i" + done >"$FIXTURE" + bashunit::helper::build_provider_map "$FIXTURE" + bashunit::helper::provider_for_function "metadata_boundary_$((size - 1))" + bashunit::helper::annotations_for_function "metadata_boundary_$((size - 1))" + + assert_same "rows_$((size - 1))" "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same "$((size - 1))" "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + if [ "$size" -eq 64 ]; then + assert_empty "$(compgen -A variable _BASHUNIT_METADATA_ANNOT_)" + else + assert_not_empty "$(compgen -A variable _BASHUNIT_METADATA_ANNOT_)" + fi + done +} + +function metadata_index_read_failure() { + case "${2-}" in + _BASHUNIT_METADATA_*_metadata__padding__*) return 1 ;; + *) + # shellcheck disable=SC2162 # Forward the caller's read flags unchanged. + builtin read "$@" + ;; + esac +} + +function test_index_setup_failure_keeps_metadata_available_and_cleans_partial_entries() { + bashunit::mock read metadata_index_read_failure + build_indexed_map "# @timeout 3 +# @data_provider first_rows +${FN}() { :; }" + + bashunit::helper::provider_for_function metadata_padding_64 + bashunit::helper::annotations_for_function metadata_padding_64 + assert_same rows "$_BASHUNIT_PROVIDER_FN_OUT" + assert_same 0 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + bashunit::helper::annotations_for_function "$FN" + assert_same 3 "$_BASHUNIT_ANNOT_TIMEOUT_OUT" + + FIXTURE="$(bashunit::temp_file)" + build_map "${FN}() { :; }" + assert_empty "$(compgen -A variable _BASHUNIT_METADATA_ANNOT_)" + assert_empty "$(compgen -A variable _BASHUNIT_METADATA_PROVIDER_)" +}