Skip to content

Commit e2f6e5d

Browse files
authored
testers.testEqualArrayOrMap: init (#383214)
2 parents 3b0cc6e + 674c907 commit e2f6e5d

File tree

9 files changed

+580
-0
lines changed

9 files changed

+580
-0
lines changed

doc/build-helpers/testers.chapter.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,97 @@ testers.testEqualContents {
347347

348348
:::
349349

350+
## `testEqualArrayOrMap` {#tester-testEqualArrayOrMap}
351+
352+
Check that bash arrays (including associative arrays, referred to as "maps") are populated correctly.
353+
354+
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.
355+
356+
:::{.example #ex-testEqualArrayOrMap-test-function-add-cowbell}
357+
358+
# Test a function which appends a value to an array
359+
360+
```nix
361+
testers.testEqualArrayOrMap {
362+
name = "test-function-add-cowbell";
363+
valuesArray = [
364+
"cowbell"
365+
"cowbell"
366+
];
367+
expectedArray = [
368+
"cowbell"
369+
"cowbell"
370+
"cowbell"
371+
];
372+
script = ''
373+
addCowbell() {
374+
local -rn arrayNameRef="$1"
375+
arrayNameRef+=( "cowbell" )
376+
}
377+
378+
nixLog "appending all values in valuesArray to actualArray"
379+
for value in "''${valuesArray[@]}"; do
380+
actualArray+=( "$value" )
381+
done
382+
383+
nixLog "applying addCowbell"
384+
addCowbell actualArray
385+
'';
386+
}
387+
```
388+
389+
:::
390+
391+
### Inputs {#tester-testEqualArrayOrMap-inputs}
392+
393+
NOTE: Internally, this tester uses `__structuredAttrs` to handle marshalling between Nix expressions and shell variables.
394+
This imposes the restriction that arrays and "maps" have values which are string-like.
395+
396+
NOTE: At least one of `expectedArray` and `expectedMap` must be provided.
397+
398+
`name` (string)
399+
400+
: The name of the test.
401+
402+
`script` (string)
403+
404+
: The singular task of `script` is to populate `actualArray` or `actualMap` (it may populate both).
405+
To do this, `script` may access the following shell variables:
406+
407+
- `valuesArray` (available when `valuesArray` is provided to the tester)
408+
- `valuesMap` (available when `valuesMap` is provided to the tester)
409+
- `actualArray` (available when `expectedArray` is provided to the tester)
410+
- `actualMap` (available when `expectedMap` is provided to the tester)
411+
412+
While both `expectedArray` and `expectedMap` are in scope during the execution of `script`, they *must not* be accessed or modified from within `script`.
413+
414+
`valuesArray` (array of string-like values, optional)
415+
416+
: An array of string-like values.
417+
This array may be used within `script`.
418+
419+
`valuesMap` (attribute set of string-like values, optional)
420+
421+
: An attribute set of string-like values.
422+
This attribute set may be used within `script`.
423+
424+
`expectedArray` (array of string-like values, optional)
425+
426+
: An array of string-like values.
427+
This array *must not* be accessed or modified from within `script`.
428+
When provided, `script` is expected to populate `actualArray`.
429+
430+
`expectedMap` (attribute set of string-like values, optional)
431+
432+
: An attribute set of string-like values.
433+
This attribute set *must not* be accessed or modified from within `script`.
434+
When provided, `script` is expected to populate `actualMap`.
435+
436+
### Return value {#tester-testEqualArrayOrMap-return}
437+
438+
The tester produces an empty output and only succeeds when `expectedArray` and `expectedMap` match `actualArray` and `actualMap`, respectively, when non-null.
439+
The build log will contain differences encountered.
440+
350441
## `testEqualDerivation` {#tester-testEqualDerivation}
351442

352443
Checks that two packages produce the exact same build instructions.

doc/redirects.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
"ex-testBuildFailurePrime-doc-example": [
1212
"index.html#ex-testBuildFailurePrime-doc-example"
1313
],
14+
"ex-testEqualArrayOrMap-test-function-add-cowbell": [
15+
"index.html#ex-testEqualArrayOrMap-test-function-add-cowbell"
16+
],
1417
"neovim": [
1518
"index.html#neovim"
1619
],
@@ -344,6 +347,15 @@
344347
"tester-testBuildFailurePrime-return": [
345348
"index.html#tester-testBuildFailurePrime-return"
346349
],
350+
"tester-testEqualArrayOrMap": [
351+
"index.html#tester-testEqualArrayOrMap"
352+
],
353+
"tester-testEqualArrayOrMap-inputs": [
354+
"index.html#tester-testEqualArrayOrMap-inputs"
355+
],
356+
"tester-testEqualArrayOrMap-return": [
357+
"index.html#tester-testEqualArrayOrMap-return"
358+
],
347359
"variables-specifying-dependencies": [
348360
"index.html#variables-specifying-dependencies"
349361
],

pkgs/build-support/testers/default.nix

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@
6969
fi
7070
'';
7171

72+
# See https://nixos.org/manual/nixpkgs/unstable/#tester-testEqualArrayOrMap
73+
# or doc/build-helpers/testers.chapter.md
74+
testEqualArrayOrMap = callPackage ./testEqualArrayOrMap { };
75+
7276
# See https://nixos.org/manual/nixpkgs/unstable/#tester-testVersion
7377
# or doc/build-helpers/testers.chapter.md
7478
testVersion =

pkgs/build-support/testers/test/default.nix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,4 +356,6 @@ lib.recurseIntoAttrs {
356356
touch -- "$out"
357357
'';
358358
};
359+
360+
testEqualArrayOrMap = pkgs.callPackages ../testEqualArrayOrMap/tests.nix { };
359361
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# shellcheck shell=bash
2+
3+
# Tests if an array is declared.
4+
isDeclaredArray() {
5+
# shellcheck disable=SC2034
6+
local -nr arrayRef="$1" && [[ ${!arrayRef@a} =~ a ]]
7+
}
8+
9+
# Asserts that two arrays are equal, printing out differences if they are not.
10+
# Does not short circuit on the first difference.
11+
assertEqualArray() {
12+
if (($# != 2)); then
13+
nixErrorLog "expected two arguments!"
14+
nixErrorLog "usage: assertEqualArray expectedArrayRef actualArrayRef"
15+
exit 1
16+
fi
17+
18+
local -nr expectedArrayRef="$1"
19+
local -nr actualArrayRef="$2"
20+
21+
if ! isDeclaredArray "${!expectedArrayRef}"; then
22+
nixErrorLog "first arugment expectedArrayRef must be an array reference to a declared array"
23+
exit 1
24+
fi
25+
26+
if ! isDeclaredArray "${!actualArrayRef}"; then
27+
nixErrorLog "second arugment actualArrayRef must be an array reference to a declared array"
28+
exit 1
29+
fi
30+
31+
local -ir expectedLength=${#expectedArrayRef[@]}
32+
local -ir actualLength=${#actualArrayRef[@]}
33+
34+
local -i hasDiff=0
35+
36+
if ((expectedLength != actualLength)); then
37+
nixErrorLog "arrays differ in length: expectedArray has length $expectedLength but actualArray has length $actualLength"
38+
hasDiff=1
39+
fi
40+
41+
local -i idx=0
42+
local expectedValue
43+
local actualValue
44+
45+
# We iterate so long as at least one array has indices we've not considered.
46+
# This means that `idx` is a valid index to *at least one* of the arrays.
47+
for ((idx = 0; idx < expectedLength || idx < actualLength; idx++)); do
48+
# Update values for variables which are still in range/valid.
49+
if ((idx < expectedLength)); then
50+
expectedValue="${expectedArrayRef[idx]}"
51+
fi
52+
53+
if ((idx < actualLength)); then
54+
actualValue="${actualArrayRef[idx]}"
55+
fi
56+
57+
# Handle comparisons.
58+
if ((idx >= expectedLength)); then
59+
nixErrorLog "arrays differ at index $idx: expectedArray has no such index but actualArray has value ${actualValue@Q}"
60+
hasDiff=1
61+
elif ((idx >= actualLength)); then
62+
nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has no such index"
63+
hasDiff=1
64+
elif [[ $expectedValue != "$actualValue" ]]; then
65+
nixErrorLog "arrays differ at index $idx: expectedArray has value ${expectedValue@Q} but actualArray has value ${actualValue@Q}"
66+
hasDiff=1
67+
fi
68+
done
69+
70+
((hasDiff)) && exit 1 || return 0
71+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# shellcheck shell=bash
2+
3+
# Tests if a map is declared.
4+
isDeclaredMap() {
5+
# shellcheck disable=SC2034
6+
local -nr mapRef="$1" && [[ ${!mapRef@a} =~ A ]]
7+
}
8+
9+
# Asserts that two maps are equal, printing out differences if they are not.
10+
# Does not short circuit on the first difference.
11+
assertEqualMap() {
12+
if (($# != 2)); then
13+
nixErrorLog "expected two arguments!"
14+
nixErrorLog "usage: assertEqualMap expectedMapRef actualMapRef"
15+
exit 1
16+
fi
17+
18+
local -nr expectedMapRef="$1"
19+
local -nr actualMapRef="$2"
20+
21+
if ! isDeclaredMap "${!expectedMapRef}"; then
22+
nixErrorLog "first arugment expectedMapRef must be an associative array reference to a declared associative array"
23+
exit 1
24+
fi
25+
26+
if ! isDeclaredMap "${!actualMapRef}"; then
27+
nixErrorLog "second arugment actualMapRef must be an associative array reference to a declared associative array"
28+
exit 1
29+
fi
30+
31+
# NOTE:
32+
# From the `sort` manpage: "The locale specified by the environment affects sort order. Set LC_ALL=C to get the
33+
# traditional sort order that uses native byte values."
34+
# We specify the environment variable in a subshell to avoid polluting the caller's environment.
35+
36+
local -a sortedExpectedKeys
37+
mapfile -d '' -t sortedExpectedKeys < <(printf '%s\0' "${!expectedMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)
38+
39+
local -a sortedActualKeys
40+
mapfile -d '' -t sortedActualKeys < <(printf '%s\0' "${!actualMapRef[@]}" | LC_ALL=C sort --stable --zero-terminated)
41+
42+
local -ir expectedLength=${#expectedMapRef[@]}
43+
local -ir actualLength=${#actualMapRef[@]}
44+
45+
local -i hasDiff=0
46+
47+
if ((expectedLength != actualLength)); then
48+
nixErrorLog "maps differ in length: expectedMap has length $expectedLength but actualMap has length $actualLength"
49+
hasDiff=1
50+
fi
51+
52+
local -i expectedKeyIdx=0
53+
local expectedKey
54+
local expectedValue
55+
local -i actualKeyIdx=0
56+
local actualKey
57+
local actualValue
58+
59+
# We iterate so long as at least one map has keys we've not considered.
60+
while ((expectedKeyIdx < expectedLength || actualKeyIdx < actualLength)); do
61+
# Update values for variables which are still in range/valid.
62+
if ((expectedKeyIdx < expectedLength)); then
63+
expectedKey="${sortedExpectedKeys["$expectedKeyIdx"]}"
64+
expectedValue="${expectedMapRef["$expectedKey"]}"
65+
fi
66+
67+
if ((actualKeyIdx < actualLength)); then
68+
actualKey="${sortedActualKeys["$actualKeyIdx"]}"
69+
actualValue="${actualMapRef["$actualKey"]}"
70+
fi
71+
72+
# In the case actualKeyIdx is valid and expectedKey comes after actualKey or expectedKeyIdx is invalid, actualMap
73+
# has an extra key relative to expectedMap.
74+
# NOTE: In Bash, && and || have the same precedence, so use the fact they're left-associative to enforce groups.
75+
if ((actualKeyIdx < actualLength)) && [[ $expectedKey > $actualKey ]] || ((expectedKeyIdx >= expectedLength)); then
76+
nixErrorLog "maps differ at key ${actualKey@Q}: expectedMap has no such key but actualMap has value ${actualValue@Q}"
77+
hasDiff=1
78+
actualKeyIdx+=1
79+
80+
# In the case actualKeyIdx is invalid or expectedKey comes before actualKey, expectedMap has an extra key relative
81+
# to actualMap.
82+
# NOTE: By virtue of the previous condition being false, we know the negation is true. Namely, expectedKeyIdx is
83+
# valid AND (actualKeyIdx is invalid OR expectedKey <= actualKey).
84+
elif ((actualKeyIdx >= actualLength)) || [[ $expectedKey < $actualKey ]]; then
85+
nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has no such key"
86+
hasDiff=1
87+
expectedKeyIdx+=1
88+
89+
# In the case where both key indices are valid and the keys are equal.
90+
else
91+
if [[ $expectedValue != "$actualValue" ]]; then
92+
nixErrorLog "maps differ at key ${expectedKey@Q}: expectedMap has value ${expectedValue@Q} but actualMap has value ${actualValue@Q}"
93+
hasDiff=1
94+
fi
95+
96+
expectedKeyIdx+=1
97+
actualKeyIdx+=1
98+
fi
99+
done
100+
101+
((hasDiff)) && exit 1 || return 0
102+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# shellcheck shell=bash
2+
3+
set -eu
4+
5+
# NOTE: If neither expectedArray nor expectedMap are declared, the test is meaningless.
6+
# This precondition is checked in the Nix expression through an assert.
7+
8+
preScript() {
9+
if isDeclaredArray valuesArray; then
10+
# shellcheck disable=SC2154
11+
nixLog "using valuesArray: $(declare -p valuesArray)"
12+
fi
13+
14+
if isDeclaredMap valuesMap; then
15+
# shellcheck disable=SC2154
16+
nixLog "using valuesMap: $(declare -p valuesMap)"
17+
fi
18+
19+
if isDeclaredArray expectedArray; then
20+
# shellcheck disable=SC2154
21+
nixLog "using expectedArray: $(declare -p expectedArray)"
22+
declare -ag actualArray=()
23+
fi
24+
25+
if isDeclaredMap expectedMap; then
26+
# shellcheck disable=SC2154
27+
nixLog "using expectedMap: $(declare -p expectedMap)"
28+
declare -Ag actualMap=()
29+
fi
30+
31+
return 0
32+
}
33+
34+
scriptPhase() {
35+
runHook preScript
36+
37+
runHook script
38+
39+
runHook postScript
40+
}
41+
42+
postScript() {
43+
if isDeclaredArray expectedArray; then
44+
nixLog "using actualArray: $(declare -p actualArray)"
45+
nixLog "comparing actualArray against expectedArray"
46+
assertEqualArray expectedArray actualArray
47+
nixLog "actualArray matches expectedArray"
48+
fi
49+
50+
if isDeclaredMap expectedMap; then
51+
nixLog "using actualMap: $(declare -p actualMap)"
52+
nixLog "comparing actualMap against expectedMap"
53+
assertEqualMap expectedMap actualMap
54+
nixLog "actualMap matches expectedMap"
55+
fi
56+
57+
return 0
58+
}
59+
60+
runHook scriptPhase
61+
touch "${out:?}"

0 commit comments

Comments
 (0)