Skip to content

Commit 49b882e

Browse files
fselmofelix314159
andauthored
chore(test-types): adds extra modifier validation; adds unit tests (#2710)
* chore: adds extra modifier validation; adds unit tests * chore: add a blip about unit testing to the claude test-writing skill * feat: add test that showcases the issue that `found_index` can still be `True` from an earlier call * fix: fix state leak in modifiers; add tests to catch this --------- Co-authored-by: Felix H <felix314159@users.noreply.github.com>
1 parent 8a85b54 commit 49b882e

3 files changed

Lines changed: 188 additions & 22 deletions

File tree

.claude/commands/write-test.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ Conventions and patterns for writing consensus tests. Run this skill before writ
6161
- `@pytest.mark.parametrize("name", [pytest.param(val, id="label"), ...])` with descriptive `id=` strings
6262
- Stack parametrize decorators for multiple dimensions
6363

64+
## Unit Tests (execution_testing package)
65+
66+
Plain pytest. Tests are co-located with each module under `packages/testing/src/execution_testing/` in a sibling `tests/` directory. When adding a guardrail or validation, verify the tests fail without the change and pass with it.
67+
6468
## After Writing Tests
6569

6670
After writing or modifying tests, ask the user: "Would you like me to load the `/fill-tests` skill to verify the new tests fill correctly? (This loads an additional skill into context.)" If they agree, run `/fill-tests`, fill the new tests, then inspect the generated fixture JSON to verify the fixture contents match what the test intends.

packages/testing/src/execution_testing/test_types/block_access_list/modifiers.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,9 @@ def _remove_field_from_accounts(
2828
) -> Callable[[BlockAccessList], BlockAccessList]:
2929
"""Abstracted helper to remove a field from specified accounts."""
3030
len_addresses = len(addresses)
31-
found_addresses = set()
3231

3332
def transform(bal: BlockAccessList) -> BlockAccessList:
34-
nonlocal found_addresses
33+
found_addresses: set[Address] = set()
3534
new_root = []
3635
for account_change in bal.root:
3736
if account_change.address in addresses:
@@ -70,10 +69,10 @@ def _modify_field_value(
7069
Abstracted helper to modify a field value for a specific account and
7170
transaction.
7271
"""
73-
found_address = False
7472

7573
def transform(bal: BlockAccessList) -> BlockAccessList:
76-
nonlocal found_address
74+
found_address = False
75+
found_index = False
7776
new_root = []
7877
for account_change in bal.root:
7978
if account_change.address == address:
@@ -84,15 +83,18 @@ def transform(bal: BlockAccessList) -> BlockAccessList:
8483
if changes:
8584
if nested and slot is not None:
8685
# nested structure (storage)
86+
found_slot = False
8787
for storage_slot in changes:
8888
if storage_slot.slot == slot:
89+
found_slot = True
8990
for j, change in enumerate(
9091
storage_slot.slot_changes
9192
):
9293
if (
9394
change.block_access_index
9495
== block_access_index
9596
):
97+
found_index = True
9698
kwargs = {
9799
"block_access_index": (
98100
block_access_index
@@ -104,10 +106,16 @@ def transform(bal: BlockAccessList) -> BlockAccessList:
104106
)
105107
break
106108
break
109+
if not found_slot:
110+
raise ValueError(
111+
f"Storage slot {slot} not found in "
112+
f"storage_changes of account {address}"
113+
)
107114
else:
108115
# flat structure (nonce, balance, code)
109116
for i, change in enumerate(changes):
110117
if change.block_access_index == block_access_index:
118+
found_index = True
111119
kwargs = {
112120
"block_access_index": block_access_index,
113121
value_field: new_value,
@@ -120,10 +128,14 @@ def transform(bal: BlockAccessList) -> BlockAccessList:
120128
new_root.append(account_change)
121129

122130
if not found_address:
123-
# sanity check that we actually found the address
124131
raise ValueError(
125132
f"Address {address} not found in BAL to modify {field_name}"
126133
)
134+
if not found_index:
135+
raise ValueError(
136+
f"Block access index {block_access_index} not found in "
137+
f"{field_name} of account {address}"
138+
)
127139

128140
return BlockAccessList(root=new_root)
129141

@@ -247,13 +259,12 @@ def swap_bal_indices(
247259
idx1: int, idx2: int
248260
) -> Callable[[BlockAccessList], BlockAccessList]:
249261
"""Swap block access indices throughout the BAL, modifying ordering."""
250-
nonce_indices = {idx1: False, idx2: False}
251-
balance_indices = nonce_indices.copy()
252-
storage_indices = nonce_indices.copy()
253-
code_indices = nonce_indices.copy()
254262

255263
def transform(bal: BlockAccessList) -> BlockAccessList:
256-
nonlocal nonce_indices, balance_indices, storage_indices, code_indices
264+
nonce_indices = {idx1: False, idx2: False}
265+
balance_indices = nonce_indices.copy()
266+
storage_indices = nonce_indices.copy()
267+
code_indices = nonce_indices.copy()
257268
new_root = []
258269
for account_change in bal.root:
259270
new_account = account_change.model_copy(deep=True)
@@ -385,10 +396,8 @@ def append_change(
385396
else:
386397
raise TypeError(f"Unsupported change type: {type(change)}")
387398

388-
found_address = False
389-
390399
def transform(bal: BlockAccessList) -> BlockAccessList:
391-
nonlocal found_address
400+
found_address = False
392401
new_root = []
393402
for account_change in bal.root:
394403
if account_change.address == account:
@@ -427,10 +436,9 @@ def append_storage(
427436
slot_changes
428437
- If change provided and slot new: creates new BalStorageSlot
429438
"""
430-
found_address = False
431439

432440
def transform(bal: BlockAccessList) -> BlockAccessList:
433-
nonlocal found_address
441+
found_address = False
434442
new_root = []
435443
for account_change in bal.root:
436444
if account_change.address == address:
@@ -477,10 +485,9 @@ def duplicate_account(
477485
address: Address,
478486
) -> Callable[[BlockAccessList], BlockAccessList]:
479487
"""Duplicate an account entry in the BAL."""
480-
address_present = False
481488

482489
def transform(bal: BlockAccessList) -> BlockAccessList:
483-
nonlocal address_present
490+
address_present = False
484491
new_root = []
485492
for account_change in bal.root:
486493
new_root.append(account_change)
@@ -514,15 +521,14 @@ def _duplicate_in_field(
514521
When sub_field and sub_match_fn are provided, find the parent entry
515522
via match_fn then duplicate within sub_field using sub_match_fn.
516523
"""
517-
found = False
518524

519525
def _copy(entry: Any) -> Any:
520526
if hasattr(entry, "model_copy"):
521527
return entry.model_copy(deep=True)
522528
return ZeroPaddedHexNumber(entry)
523529

524530
def transform(bal: BlockAccessList) -> BlockAccessList:
525-
nonlocal found
531+
found = False
526532
new_root = []
527533
for account_change in bal.root:
528534
if account_change.address == address:
@@ -663,10 +669,9 @@ def insert_storage_read(
663669
Useful for testing that a key must not appear in both
664670
storage_changes and storage_reads.
665671
"""
666-
found_address = False
667672

668673
def transform(bal: BlockAccessList) -> BlockAccessList:
669-
nonlocal found_address
674+
found_address = False
670675
new_root = []
671676
for account_change in bal.root:
672677
if account_change.address == address:
@@ -721,8 +726,14 @@ def reorder_accounts(
721726
"""Reorder accounts according to the provided index list."""
722727

723728
def transform(bal: BlockAccessList) -> BlockAccessList:
724-
if len(indices) != len(bal.root):
729+
n = len(bal.root)
730+
if len(indices) != n:
725731
raise ValueError("Index list length must match number of accounts")
732+
if sorted(indices) != list(range(n)):
733+
raise ValueError(
734+
f"Indices must be a valid permutation of 0..{n - 1}, "
735+
f"got {indices}"
736+
)
726737
new_root = [bal.root[i] for i in indices]
727738
return BlockAccessList(root=new_root)
728739

packages/testing/src/execution_testing/test_types/tests/test_block_access_list_modifiers.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Unit tests for BAL modifier functions."""
22

3+
from typing import Callable
4+
35
import pytest
46

57
from execution_testing.base_types import Address
@@ -13,6 +15,8 @@
1315
BlockAccessList,
1416
)
1517
from execution_testing.test_types.block_access_list.modifiers import (
18+
append_change,
19+
append_storage,
1620
duplicate_account,
1721
duplicate_balance_change,
1822
duplicate_code_change,
@@ -21,6 +25,13 @@
2125
duplicate_storage_read,
2226
duplicate_storage_slot,
2327
insert_storage_read,
28+
modify_balance,
29+
modify_code,
30+
modify_nonce,
31+
modify_storage,
32+
remove_nonces,
33+
reorder_accounts,
34+
swap_bal_indices,
2435
)
2536

2637
ALICE = Address(0xA)
@@ -217,3 +228,143 @@ def test_insert_storage_read_missing_address_raises() -> None:
217228
bal = BlockAccessList([BalAccountChange(address=ALICE, nonce_changes=[])])
218229
with pytest.raises(ValueError, match="not found"):
219230
insert_storage_read(CONTRACT, 1)(bal)
231+
232+
233+
def test_modify_nonce_missing_index_raises(
234+
sample_bal: BlockAccessList,
235+
) -> None:
236+
"""Raise when the block_access_index is absent from nonce_changes."""
237+
with pytest.raises(ValueError, match="not found"):
238+
modify_nonce(ALICE, 99, 42)(sample_bal)
239+
240+
241+
def test_modify_balance_missing_index_raises(
242+
sample_bal: BlockAccessList,
243+
) -> None:
244+
"""Raise when the block_access_index is absent from balance_changes."""
245+
with pytest.raises(ValueError, match="not found"):
246+
modify_balance(ALICE, 99, 9999)(sample_bal)
247+
248+
249+
def test_modify_code_missing_index_raises(sample_bal: BlockAccessList) -> None:
250+
"""Raise when the block_access_index is absent from code_changes."""
251+
with pytest.raises(ValueError, match="not found"):
252+
modify_code(ALICE, 99, b"\x00")(sample_bal)
253+
254+
255+
def test_modify_storage_missing_index_raises(
256+
sample_bal: BlockAccessList,
257+
) -> None:
258+
"""Raise when block_access_index is absent within the storage slot."""
259+
with pytest.raises(ValueError, match="not found"):
260+
modify_storage(CONTRACT, 99, 1, 0xFF)(sample_bal)
261+
262+
263+
def test_modify_storage_missing_slot_raises(
264+
sample_bal: BlockAccessList,
265+
) -> None:
266+
"""Raise when the storage slot itself is absent."""
267+
with pytest.raises(ValueError, match="not found"):
268+
modify_storage(CONTRACT, 1, 99, 0xFF)(sample_bal)
269+
270+
271+
def test_modify_nonce_reused_callable_missing_index_still_raises() -> None:
272+
"""Raise even when the same modifier callable is reused across BALs."""
273+
modifier = modify_nonce(ALICE, 1, 42)
274+
valid_bal = BlockAccessList(
275+
[
276+
BalAccountChange(
277+
address=ALICE,
278+
nonce_changes=[
279+
BalNonceChange(block_access_index=1, post_nonce=1),
280+
],
281+
)
282+
]
283+
)
284+
missing_index_bal = BlockAccessList(
285+
[
286+
BalAccountChange(
287+
address=ALICE,
288+
nonce_changes=[],
289+
)
290+
]
291+
)
292+
293+
modifier(valid_bal)
294+
295+
with pytest.raises(ValueError, match="not found"):
296+
modifier(missing_index_bal)
297+
298+
299+
def test_reorder_accounts_duplicate_index_raises(
300+
sample_bal: BlockAccessList,
301+
) -> None:
302+
"""Raise when indices contain duplicates (not a valid permutation)."""
303+
with pytest.raises(ValueError, match="valid permutation"):
304+
reorder_accounts([0, 0])(sample_bal)
305+
306+
307+
def test_reorder_accounts_out_of_range_raises(
308+
sample_bal: BlockAccessList,
309+
) -> None:
310+
"""Raise when indices are not a valid permutation (skipped index)."""
311+
with pytest.raises(ValueError, match="valid permutation"):
312+
reorder_accounts([0, 2])(sample_bal)
313+
314+
315+
_EMPTY_BAL = BlockAccessList([])
316+
_ALICE_ONLY_BAL = BlockAccessList(
317+
[BalAccountChange(address=ALICE, nonce_changes=[])]
318+
)
319+
320+
321+
@pytest.mark.parametrize(
322+
"modifier_factory, missing_bal",
323+
[
324+
pytest.param(
325+
lambda: remove_nonces(ALICE), _EMPTY_BAL, id="remove_nonces"
326+
),
327+
pytest.param(
328+
lambda: swap_bal_indices(1, 1), _EMPTY_BAL, id="swap_bal_indices"
329+
),
330+
pytest.param(
331+
lambda: append_change(
332+
ALICE, BalNonceChange(block_access_index=2, post_nonce=5)
333+
),
334+
_EMPTY_BAL,
335+
id="append_change",
336+
),
337+
pytest.param(
338+
lambda: append_storage(CONTRACT, slot=7, read=True),
339+
_EMPTY_BAL,
340+
id="append_storage",
341+
),
342+
pytest.param(
343+
lambda: duplicate_account(ALICE),
344+
_EMPTY_BAL,
345+
id="duplicate_account",
346+
),
347+
pytest.param(
348+
lambda: duplicate_nonce_change(ALICE, 1),
349+
_ALICE_ONLY_BAL,
350+
id="duplicate_nonce_change",
351+
),
352+
pytest.param(
353+
lambda: insert_storage_read(CONTRACT, 99),
354+
_EMPTY_BAL,
355+
id="insert_storage_read",
356+
),
357+
],
358+
)
359+
def test_reused_callable_does_not_carry_found_state(
360+
sample_bal: BlockAccessList,
361+
modifier_factory: Callable[
362+
[], Callable[[BlockAccessList], BlockAccessList]
363+
],
364+
missing_bal: BlockAccessList,
365+
) -> None:
366+
"""A modifier's found-state must not persist across calls."""
367+
modifier = modifier_factory()
368+
modifier(sample_bal)
369+
with pytest.raises(ValueError, match="not found"):
370+
modifier(missing_bal)

0 commit comments

Comments
 (0)