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]