From 55a1c6ac927e996d3140be806ff67224d8eaafda Mon Sep 17 00:00:00 2001 From: "gk.monk" Date: Sat, 26 Sep 2026 10:29:35 +0200 Subject: [PATCH] fix(testing): tell the fixture when slot processing is skipped Closes #1198. Two state-transition tests process a block against a state deliberately left behind the block's slot, because that mismatch is the rejection they test. The spec does that by skipping slot processing, but the emitted fixture said nothing about it, so a runner replaying the fixture ran the whole state transition, advanced the slot first, never reached the mismatch, and had no way to know why. The fixture now carries a per-block `skipSlotProcessing` list. It is emitted only when some block needs it, so every ordinary fixture stays byte-identical, and an absent field keeps meaning what runners already do. Co-Authored-By: Claude Opus 5 (1M context) --- .../test_fixtures/state_transition.py | 17 +++++ .../testing/test_state_transition_fixture.py | 62 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/testing/test_state_transition_fixture.py diff --git a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py index c11c67fc4..898850adc 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -38,6 +38,17 @@ class StateTransitionFixture(BaseConsensusFixture): post_state_root: Bytes32 | None = None """Hash tree root of the full post-state, so two clients cannot pass with divergent state.""" + skip_slot_processing: list[bool] | None = None + """ + Per block, whether the runner must skip slot processing before processing that block. + + Most tests advance slots for every block, and there the field is absent. It is emitted + only by the tests that deliberately leave the state behind the block's slot, because + that is the state they are testing the rejection of. A runner that calls the whole state + transition for such a block advances the slot first and never reaches the mismatch, so + without this the fixture cannot be reproduced from its own contents. + """ + class StateTransitionTest(BaseTestSpec): """ @@ -140,12 +151,18 @@ def generate(self) -> StateTransitionFixture: if self.post is not None and actual_post_state is not None: self.post.validate_against_state(actual_post_state, block_registry=block_registry) + # Emitted only when some block needs it, so the ordinary fixture is unchanged. + skip_slot_processing = [block_spec.skip_slot_processing for block_spec in self.blocks] + if not any(skip_slot_processing): + skip_slot_processing = None + return StateTransitionFixture( pre=self.pre, blocks=filled_blocks, post=self.post, post_state_root=post_state_root, rejection_reason=rejection_reason, + skip_slot_processing=skip_slot_processing, ) def _build_block_from_spec( diff --git a/tests/testing/test_state_transition_fixture.py b/tests/testing/test_state_transition_fixture.py new file mode 100644 index 000000000..cb90daedd --- /dev/null +++ b/tests/testing/test_state_transition_fixture.py @@ -0,0 +1,62 @@ +"""The emitted state-transition fixture must be reproducible from its own contents.""" + +from __future__ import annotations + +from consensus_testing import BlockSpec, ExpectedRejection, build_genesis_state +from consensus_testing.test_fixtures.state_transition import StateTransitionTest +from lean_spec.spec.forks import RejectionReason, Slot +from lean_spec.spec.forks.lstar.spec import LstarSpec + + +def test_an_ordinary_fixture_carries_no_slot_processing_instruction() -> None: + """A test that advances slots normally emits nothing extra, as every runner does that.""" + fixture = StateTransitionTest( + pre=build_genesis_state(), + blocks=[BlockSpec(slot=Slot(1)), BlockSpec(slot=Slot(2))], + ).generate() + + assert fixture.skip_slot_processing is None + + +def test_a_skipped_slot_processing_block_says_so_in_the_fixture() -> None: + """ + A block the spec processes without advancing slots must say so. + + The rejection under test only happens while the state is behind the block's slot. A + runner that calls the whole state transition advances the slot first, never reaches the + mismatch, and reports the fixture as broken rather than reproducing it. + """ + pre = LstarSpec().process_slots(build_genesis_state(), Slot(1)) + + fixture = StateTransitionTest( + pre=pre, + blocks=[BlockSpec(slot=Slot(2), skip_slot_processing=True)], + expected_rejection=ExpectedRejection( + reason=RejectionReason.BLOCK_SLOT_MISMATCH, + exact_message="Block slot mismatch", + ), + ).generate() + + assert fixture.skip_slot_processing == [True] + + +def test_the_instruction_is_per_block_and_in_order() -> None: + """ + A run that mixes both kinds pins which block skips, not merely that one of them does. + + A single flag, or a list built in the wrong order, would pass a test where every block + skips. This one would not. + """ + pre = LstarSpec().process_slots(build_genesis_state(), Slot(1)) + + fixture = StateTransitionTest( + pre=pre, + blocks=[ + # Processed against a state already at slot 1, so slots must not advance again. + BlockSpec(slot=Slot(1), skip_slot_processing=True), + # An ordinary block, which the runner advances to as usual. + BlockSpec(slot=Slot(2)), + ], + ).generate() + + assert fixture.skip_slot_processing == [True, False]