Skip to content

Commit

Permalink
Merge branch 'feat/testEqualArrayOrMap' into feat/testEqualArrayOrMap…
Browse files Browse the repository at this point in the history
…-uses-testBuildFailurePrime
  • Loading branch information
ConnorBaker committed Feb 21, 2025
2 parents 8e92398 + 96bcdf7 commit 966a622
Show file tree
Hide file tree
Showing 9 changed files with 668 additions and 0 deletions.
91 changes: 91 additions & 0 deletions doc/build-helpers/testers.chapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,97 @@ testers.testEqualContents {

:::

## `testEqualArrayOrMap` {#tester-testEqualArrayOrMap}

Check that bash arrays (including associative arrays, referred to as "maps") are populated correctly.

This can be used to ensure setup hooks are registered in a certain order, or to write unit tests for shell functions which transform arrays.

:::{.example #ex-testEqualArrayOrMap-test-function-add-cowbell}

# Test a function which appends a value to an array

```nix
testers.testEqualArrayOrMap {
name = "test-function-add-cowbell";
valuesArray = [
"cowbell"
"cowbell"
];
expectedArray = [
"cowbell"
"cowbell"
"cowbell"
];
script = ''
addCowbell() {
local -rn arrayNameRef="$1"
arrayNameRef+=( "cowbell" )
}
nixLog "appending all values in valuesArray to actualArray"
for value in "''${valuesArray[@]}"; do
actualArray+=( "$value" )
done
nixLog "applying addCowbell"
addCowbell actualArray
'';
}
```

:::

### Inputs {#tester-testEqualArrayOrMap-inputs}

NOTE: Internally, this tester uses `__structuredAttrs` to handle marshalling between Nix expressions and shell variables.
This imposes the restriction that arrays and "maps" have values which are string-like.

NOTE: At least one of `expectedArray` and `expectedMap` must be provided.

`name` (string)

: The name of the test.

`script` (string)

: The singular task of `script` is to populate `actualArray` or `actualMap` (it may populate both).
To do this, `script` may access the following shell variables:

- `valuesArray` (available when `valuesArray` is provided to the tester)
- `valuesMap` (available when `valuesMap` is provided to the tester)
- `actualArray` (available when `expectedArray` is provided to the tester)
- `actualMap` (available when `expectedMap` is provided to the tester)

While both `expectedArray` and `expectedMap` are in scope during the execution of `script`, they *must not* be accessed or modified from within `script`.

`valuesArray` (array of string-like values, optional)

: An array of string-like values.
This array may be used within `script`.

`valuesMap` (attribute set of string-like values, optional)

: An attribute set of string-like values.
This attribute set may be used within `script`.

`expectedArray` (array of string-like values, optional)

: An array of string-like values.
This array *must not* be accessed or modified from within `script`.
When provided, `script` is expected to populate `actualArray`.

`expectedMap` (attribute set of string-like values, optional)

: An attribute set of string-like values.
This attribute set *must not* be accessed or modified from within `script`.
When provided, `script` is expected to populate `actualMap`.

### Return value {#tester-testEqualArrayOrMap-return}

The tester produces an empty output and only succeeds when `expectedArray` and `expectedMap` match `actualArray` and `actualMap`, respectively, when non-null.
The build log will contain differences encountered.

## `testEqualDerivation` {#tester-testEqualDerivation}

Checks that two packages produce the exact same build instructions.
Expand Down
12 changes: 12 additions & 0 deletions doc/redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"ex-testBuildFailurePrime-doc-example": [
"index.html#ex-testBuildFailurePrime-doc-example"
],
"ex-testEqualArrayOrMap-test-function-add-cowbell": [
"index.html#ex-testEqualArrayOrMap-test-function-add-cowbell"
],
"neovim": [
"index.html#neovim"
],
Expand Down Expand Up @@ -344,6 +347,15 @@
"tester-testBuildFailurePrime-return": [
"index.html#tester-testBuildFailurePrime-return"
],
"tester-testEqualArrayOrMap": [
"index.html#tester-testEqualArrayOrMap"
],
"tester-testEqualArrayOrMap-inputs": [
"index.html#tester-testEqualArrayOrMap-inputs"
],
"tester-testEqualArrayOrMap-return": [
"index.html#tester-testEqualArrayOrMap-return"
],
"variables-specifying-dependencies": [
"index.html#variables-specifying-dependencies"
],
Expand Down
5 changes: 5 additions & 0 deletions pkgs/build-support/testers/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@
fi
'';

# See https://nixos.org/manual/nixpkgs/unstable/#tester-testEqualArrayOrMap
# or doc/build-helpers/testers.chapter.md
# NOTE: Must be `import`-ed rather than `callPackage`-d to preserve the `override` attribute.
testEqualArrayOrMap = import ./testEqualArrayOrMap/tester.nix { inherit lib stdenvNoCC; };

# See https://nixos.org/manual/nixpkgs/unstable/#tester-testVersion
# or doc/build-helpers/testers.chapter.md
testVersion =
Expand Down
2 changes: 2 additions & 0 deletions pkgs/build-support/testers/test/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,6 @@ lib.recurseIntoAttrs {
touch -- "$out"
'';
};

testEqualArrayOrMap = lib.recurseIntoAttrs (pkgs.callPackages ../testEqualArrayOrMap/tests.nix { });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# shellcheck shell=bash

# Tests if an array is declared.
isDeclaredArray() {
# shellcheck disable=SC2034
local -nr arrayRef="$1" && [[ ${!arrayRef@a} =~ a ]]
}

# Asserts that two arrays are equal, printing out differences if they are not.
# Does not short circuit on the first difference.
assertEqualArray() {
if (($# != 2)); then
nixErrorLog "expected two arguments!"
nixErrorLog "usage: assertEqualArray expectedArrayRef actualArrayRef"
exit 1
fi

local -nr expectedArrayRef="$1"
local -nr actualArrayRef="$2"

if ! isDeclaredArray expectedArrayRef; then
nixErrorLog "first arugment expectedArrayRef must be an array reference to a declared array"
exit 1
fi

if ! isDeclaredArray actualArrayRef; then
nixErrorLog "second arugment actualArrayRef must be an array reference to a declared array"
exit 1
fi

local -ir expectedLength=${#expectedArrayRef[@]}
local -ir actualLength=${#actualArrayRef[@]}

local -i hasDiff=0

if ((expectedLength != actualLength)); then
nixErrorLog "arrays differ in length: expectedArray has length $expectedLength but actualArray has length $actualLength"
hasDiff=1
fi

local -i idx=0
local expectedValue
local actualValue

# We iterate so long as at least one array has indices we've not considered.
# This means that `idx` is a valid index to *at least one* of the arrays.
for ((idx = 0; idx < expectedLength || idx < actualLength; idx++)); do
# Update values for variables which are still in range/valid.
if ((idx < expectedLength)); then
expectedValue="${expectedArrayRef[idx]}"
fi

if ((idx < actualLength)); then
actualValue="${actualArrayRef[idx]}"
fi

# Handle comparisons.
if ((idx >= expectedLength)); then
nixErrorLog "arrays differ at index $idx: expectedArray has no such index but actualArray has value ${actualValue@Q}"
hasDiff=1
elif ((idx >= actualLength)); then
nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has no such index"
hasDiff=1
elif [[ $expectedValue != "$actualValue" ]]; then
nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has value ${actualValue@Q}"
hasDiff=1
fi
done

((hasDiff)) && exit 1 || return 0
}
102 changes: 102 additions & 0 deletions pkgs/build-support/testers/testEqualArrayOrMap/assert-equal-map.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# shellcheck shell=bash

# Tests if a map is declared.
isDeclaredMap() {
# shellcheck disable=SC2034
local -nr mapRef="$1" && [[ ${!mapRef@a} =~ A ]]
}

# Asserts that two maps are equal, printing out differences if they are not.
# Does not short circuit on the first difference.
assertEqualMap() {
if (($# != 2)); then
nixErrorLog "expected two arguments!"
nixErrorLog "usage: assertEqualMap expectedMapRef actualMapRef"
exit 1
fi

local -nr expectedMapRef="$1"
local -nr actualMapRef="$2"

if ! isDeclaredMap expectedMapRef; then
nixErrorLog "first arugment expectedMapRef must be an associative array reference to a declared associative array"
exit 1
fi

if ! isDeclaredMap actualMapRef; then
nixErrorLog "second arugment actualMapRef must be an associative array reference to a declared associative array"
exit 1
fi

# NOTE:
# From the `sort` manpage: "The locale specified by the environment affects sort order. Set LC_ALL=C to get the
# traditional sort order that uses native byte values."
# We specify the environment variable in a subshell to avoid polluting the caller's environment.

local -a sortedExpectedKeys
mapfile -d '' -t sortedExpectedKeys < <(printf '%s\0' "${!expectedMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)

local -a sortedActualKeys
mapfile -d '' -t sortedActualKeys < <(printf '%s\0' "${!actualMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)

local -ir expectedLength=${#expectedMapRef[@]}
local -ir actualLength=${#actualMapRef[@]}

local -i hasDiff=0

if ((expectedLength != actualLength)); then
nixErrorLog "maps differ in length: expectedMap has length $expectedLength but actualMap has length $actualLength"
hasDiff=1
fi

local -i expectedKeyIdx=0
local expectedKey
local expectedValue
local -i actualKeyIdx=0
local actualKey
local actualValue

# We iterate so long as at least one map has keys we've not considered.
while ((expectedKeyIdx < expectedLength || actualKeyIdx < actualLength)); do
# Update values for variables which are still in range/valid.
if ((expectedKeyIdx < expectedLength)); then
expectedKey="${sortedExpectedKeys["$expectedKeyIdx"]}"
expectedValue="${expectedMapRef["$expectedKey"]}"
fi

if ((actualKeyIdx < actualLength)); then
actualKey="${sortedActualKeys["$actualKeyIdx"]}"
actualValue="${actualMapRef["$actualKey"]}"
fi

# In the case actualKeyIdx is valid and expectedKey comes after actualKey or expectedKeyIdx is invalid, actualMap
# has an extra key relative to expectedMap.
# NOTE: In Bash, && and || have the same precedence, so use the fact they're left-associative to enforce groups.
if ((actualKeyIdx < actualLength)) && [[ $expectedKey > $actualKey ]] || ((expectedKeyIdx >= expectedLength)); then
nixErrorLog "maps differ at key ${actualKey@Q}: expectedMap has no such key but actualMap has value ${actualValue@Q}"
hasDiff=1
actualKeyIdx+=1

# In the case actualKeyIdx is invalid or expectedKey comes before actualKey, expectedMap has an extra key relative
# to actualMap.
# NOTE: By virtue of the previous condition being false, we know the negation is true. Namely, expectedKeyIdx is
# valid AND (actualKeyIdx is invalid OR expectedKey <= actualKey).
elif ((actualKeyIdx >= actualLength)) || [[ $expectedKey < $actualKey ]]; then
nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has no such key"
hasDiff=1
expectedKeyIdx+=1

# In the case where both key indices are valid and the keys are equal.
else
if [[ $expectedValue != "$actualValue" ]]; then
nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has value ${actualValue@Q}"
hasDiff=1
fi

expectedKeyIdx+=1
actualKeyIdx+=1
fi
done

((hasDiff)) && exit 1 || return 0
}
61 changes: 61 additions & 0 deletions pkgs/build-support/testers/testEqualArrayOrMap/build-command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# shellcheck shell=bash

set -eu

# NOTE: If neither expectedArray nor expectedMap are declared, the test is meaningless.
# This precondition is checked in the Nix expression through an assert.

preScript() {
if isDeclaredArray valuesArray; then
# shellcheck disable=SC2154
nixLog "using valuesArray: $(declare -p valuesArray)"
fi

if isDeclaredMap valuesMap; then
# shellcheck disable=SC2154
nixLog "using valuesMap: $(declare -p valuesMap)"
fi

if isDeclaredArray expectedArray; then
# shellcheck disable=SC2154
nixLog "using expectedArray: $(declare -p expectedArray)"
declare -ag actualArray=()
fi

if isDeclaredMap expectedMap; then
# shellcheck disable=SC2154
nixLog "using expectedMap: $(declare -p expectedMap)"
declare -Ag actualMap=()
fi

return 0
}

scriptPhase() {
runHook preScript

runHook script

runHook postScript
}

postScript() {
if isDeclaredArray expectedArray; then
nixLog "using actualArray: $(declare -p actualArray)"
nixLog "comparing actualArray against expectedArray"
assertEqualArray expectedArray actualArray
nixLog "actualArray matches expectedArray"
fi

if isDeclaredMap expectedMap; then
nixLog "using actualMap: $(declare -p actualMap)"
nixLog "comparing actualMap against expectedMap"
assertEqualMap expectedMap actualMap
nixLog "actualMap matches expectedMap"
fi

return 0
}

runHook scriptPhase
touch "${out:?}"
Loading

0 comments on commit 966a622

Please sign in to comment.