diff --git a/cardano_node_tests/tests/plutus_common.py b/cardano_node_tests/tests/plutus_common.py index 67340fc62..d892835d3 100644 --- a/cardano_node_tests/tests/plutus_common.py +++ b/cardano_node_tests/tests/plutus_common.py @@ -95,6 +95,14 @@ @dataclasses.dataclass(frozen=True, order=True) class ExecutionCost: + """A script's integer execution budget and expected execution fee, not unit prices. + + Attributes: + per_time: Number of execution steps consumed by the script. + per_space: Number of memory units consumed by the script. + fixed_cost: Expected script execution fee in Lovelace. + """ + per_time: int per_space: int fixed_cost: int @@ -945,11 +953,30 @@ def check_plutus_costs( raise AssertionError("\n".join(errors)) -def get_cost_per_unit(protocol_params: dict) -> ExecutionCost: - """Get execution cost per unit in Lovelace.""" - return ExecutionCost( - per_time=protocol_params["executionUnitPrices"]["priceSteps"] or 0, - per_space=protocol_params["executionUnitPrices"]["priceMemory"] or 0, +@dataclasses.dataclass(frozen=True, order=True) +class CostPerUnit: + """Protocol prices used to convert an execution budget into a Lovelace fee. + + Attributes: + per_time: Lovelace price per execution step, which can be fractional. + per_space: Lovelace price per memory unit, which can be fractional. + fixed_cost: Protocol's fixed transaction fee (txFeeFixed), not a script fee. + """ + + per_time: float + per_space: float + fixed_cost: int + + +def get_cost_per_unit(protocol_params: dict) -> CostPerUnit: + """Get execution prices in Lovelace from numeric or rational-object parameters.""" + return CostPerUnit( + per_time=float( + helpers.get_rational_pparam(protocol_params["executionUnitPrices"]["priceSteps"] or 0) + ), + per_space=float( + helpers.get_rational_pparam(protocol_params["executionUnitPrices"]["priceMemory"] or 0) + ), fixed_cost=protocol_params["txFeeFixed"] or 0, ) diff --git a/cardano_node_tests/tests/tests_conway/conway_common.py b/cardano_node_tests/tests/tests_conway/conway_common.py index 44f990f0d..1e801a70f 100644 --- a/cardano_node_tests/tests/tests_conway/conway_common.py +++ b/cardano_node_tests/tests/tests_conway/conway_common.py @@ -1,7 +1,6 @@ """Common functionality for Conway governance tests.""" import dataclasses -import fractions import itertools import json import logging @@ -36,17 +35,6 @@ def is_in_bootstrap( return bool(clusterlib_utils.get_protocol_version(cluster_obj=cluster_obj) == 9) -def get_rational_pparam(pparam: float | dict) -> fractions.Fraction: - """Return a rational pparam as a fraction. - - The ledger can show a rational either as a number or as a dict with numerator and - denominator. - """ - if isinstance(pparam, dict): - return fractions.Fraction(pparam["numerator"], pparam["denominator"]) - return fractions.Fraction(str(pparam)) - - def check_rational_pparam( update_proposal: clusterlib_utils.UpdateProposal, protocol_params: dict ) -> bool: @@ -62,8 +50,8 @@ def check_rational_pparam( Returns: bool: True when the pparam has the proposed value. """ - pparam_val = get_rational_pparam(protocol_params[update_proposal.name]) - return bool(pparam_val == fractions.Fraction(str(update_proposal.value))) + pparam_val = helpers.get_rational_pparam(protocol_params[update_proposal.name]) + return bool(pparam_val == helpers.get_rational_pparam(update_proposal.value)) def get_committee_val(data: dict[str, tp.Any]) -> dict[str, tp.Any]: diff --git a/cardano_node_tests/tests/tests_conway/test_committee.py b/cardano_node_tests/tests/tests_conway/test_committee.py index 57dc4e2cd..5b03690c9 100644 --- a/cardano_node_tests/tests/tests_conway/test_committee.py +++ b/cardano_node_tests/tests/tests_conway/test_committee.py @@ -1,5 +1,6 @@ """Tests for Conway governance Constitutional Committee functionality.""" +import fractions import logging import pathlib as pl import random @@ -480,6 +481,7 @@ def test_update_committee_action( * Add CC Members * Update committee threshold * Check that the proposed changes are correct in `query gov-state` + * Check `governance action view` for fraction and decimal thresholds """ temp_template = common.get_test_id(cluster) cc_size = 3 @@ -606,6 +608,9 @@ def test_update_committee_action( dbsync_utils.check_committee_info(gov_state=gov_state, txid=txid) [r.success() for r in (reqc.db010, reqc.db011)] + # Check both fraction and decimal thresholds in the action view. + governance_utils.check_action_view(cluster_obj=cluster, action_data=update_action) + @allure.link(helpers.get_vcs_link()) @pytest.mark.xdist_split(markers.XdSplits.governance, markers.XdSplits.heavy) @pytest.mark.long @@ -673,13 +678,17 @@ def test_add_rm_committee_members( # noqa: C901 # would not be possible to approve any action. delegated_stake = governance_utils.get_delegated_stake(cluster_obj=cluster) cur_pparams = cluster.g_query.get_protocol_params() - drep_constitution_threshold = cur_pparams["dRepVotingThresholds"]["committeeNormal"] - spo_constitution_threshold = cur_pparams["poolVotingThresholds"]["committeeNormal"] + drep_constitution_threshold = helpers.get_rational_pparam( + cur_pparams["dRepVotingThresholds"]["committeeNormal"] + ) + spo_constitution_threshold = helpers.get_rational_pparam( + cur_pparams["poolVotingThresholds"]["committeeNormal"] + ) is_drep_total_below_threshold = ( - delegated_stake.drep / delegated_stake.total_lovelace + fractions.Fraction(delegated_stake.drep, delegated_stake.total_lovelace) ) < drep_constitution_threshold is_spo_total_below_threshold = ( - delegated_stake.spo / delegated_stake.total_lovelace + fractions.Fraction(delegated_stake.spo, delegated_stake.total_lovelace) ) < spo_constitution_threshold # Auth keys for CC members diff --git a/cardano_node_tests/tests/tests_conway/test_pparam_update.py b/cardano_node_tests/tests/tests_conway/test_pparam_update.py index 9c39af8de..ca395dd2d 100644 --- a/cardano_node_tests/tests/tests_conway/test_pparam_update.py +++ b/cardano_node_tests/tests/tests_conway/test_pparam_update.py @@ -177,10 +177,8 @@ def _get_rational_str(value: float) -> str: def _check_w_denominator( update_proposal: clusterlib_utils.UpdateProposal, pparam: float | dict ) -> bool: - exp_val: float | dict | str = pparam - if isinstance(pparam, dict): - exp_val = f"{pparam['numerator']}/{pparam['denominator']}" - return bool(update_proposal.value == exp_val) + """Compare rational parameters returned as decimals or numerator/denominator objects.""" + return helpers.get_rational_pparam(update_proposal.value) == helpers.get_rational_pparam(pparam) def _check_max_tx_execution_units( @@ -366,13 +364,17 @@ def test_pparam_update( # noqa: C901 # missing votes would approve the action. delegated_stake = governance_utils.get_delegated_stake(cluster_obj=cluster) cur_pparams = cluster.g_query.get_protocol_params() - drep_constitution_threshold = cur_pparams["dRepVotingThresholds"]["ppGovGroup"] - spo_constitution_threshold = cur_pparams["poolVotingThresholds"]["ppSecurityGroup"] + drep_constitution_threshold = helpers.get_rational_pparam( + cur_pparams["dRepVotingThresholds"]["ppGovGroup"] + ) + spo_constitution_threshold = helpers.get_rational_pparam( + cur_pparams["poolVotingThresholds"]["ppSecurityGroup"] + ) is_drep_total_below_threshold = ( - delegated_stake.drep / delegated_stake.total_lovelace + fractions.Fraction(delegated_stake.drep, delegated_stake.total_lovelace) ) < drep_constitution_threshold is_spo_total_below_threshold = ( - delegated_stake.spo / delegated_stake.total_lovelace + fractions.Fraction(delegated_stake.spo, delegated_stake.total_lovelace) ) < spo_constitution_threshold # PParam groups diff --git a/cardano_node_tests/tests/tests_dijkstra/test_leios_blocks.py b/cardano_node_tests/tests/tests_dijkstra/test_leios_blocks.py index f79b61b07..1d9dec9f9 100644 --- a/cardano_node_tests/tests/tests_dijkstra/test_leios_blocks.py +++ b/cardano_node_tests/tests/tests_dijkstra/test_leios_blocks.py @@ -1563,9 +1563,9 @@ def test_quorum_loss_and_recovery( # The quorum is read from the current protocol parameters, and the seat weights # from the committee of the current epoch. Neither changes at an epoch boundary # unless the pools or their stake change, which the cluster lock rules out. - quorum = fractions.Fraction( + quorum = helpers.get_rational_pparam( cluster.g_query.get_protocol_params()["leiosQuorumStakeThreshold"] - ).limit_denominator() + ) stopped_pool_id = helpers.get_pool_id_hex( delegation.get_pool_id( cluster_obj=cluster, diff --git a/cardano_node_tests/tests/tests_dijkstra/test_leios_pparams.py b/cardano_node_tests/tests/tests_dijkstra/test_leios_pparams.py index da88df0fa..e67d05283 100644 --- a/cardano_node_tests/tests/tests_dijkstra/test_leios_pparams.py +++ b/cardano_node_tests/tests/tests_dijkstra/test_leios_pparams.py @@ -229,7 +229,7 @@ def test_committee_size_and_quorum( quorum = fractions.Fraction( min(pool_shares.values()) * fractions.Fraction(QUORUM_STAKE_SHARE_RATIO) ).limit_denominator(1000) - init_quorum = conway_common.get_rational_pparam( + init_quorum = helpers.get_rational_pparam( cluster.g_query.get_protocol_params()["leiosQuorumStakeThreshold"] ) assert 0 < quorum < init_quorum, ( diff --git a/cardano_node_tests/tests/tests_plutus/test_spend_negative_raw.py b/cardano_node_tests/tests/tests_plutus/test_spend_negative_raw.py index 0e86e6e87..9cb66368a 100644 --- a/cardano_node_tests/tests/tests_plutus/test_spend_negative_raw.py +++ b/cardano_node_tests/tests/tests_plutus/test_spend_negative_raw.py @@ -811,7 +811,8 @@ def fund_script_guessing_game_v2( def cost_per_unit( self, cluster: clusterlib.ClusterLib, - ) -> plutus_common.ExecutionCost: + ) -> plutus_common.CostPerUnit: + """Return the current execution unit prices and fixed transaction fee.""" return plutus_common.get_cost_per_unit( protocol_params=cluster.g_query.get_protocol_params() ) @@ -824,7 +825,7 @@ def _failed_tx_build( collateral_utxos: list[clusterlib.UTXOData], redeemer_content: str, dst_addr: clusterlib.AddressRecord, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, ) -> str: """Try to build a Tx and expect failure.""" @@ -875,7 +876,7 @@ def _int_out_of_range( collateral_utxos: list[clusterlib.UTXOData], redeemer_value: int, dst_addr: clusterlib.AddressRecord, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, ) -> str: """Try to spend a locked UTxO with redeemer int value that is not in allowed range.""" @@ -942,7 +943,7 @@ def test_wrong_value_inside_range( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: int, ): @@ -1053,7 +1054,7 @@ def test_wrong_value_bellow_range( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: int, ): @@ -1108,7 +1109,7 @@ def test_wrong_value_above_range( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: int, ): @@ -1162,7 +1163,7 @@ def test_wrong_type( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: bytes, ): @@ -1265,7 +1266,7 @@ def test_too_big( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: bytes, ): @@ -1354,7 +1355,7 @@ def test_json_schema_typed_int_bytes_declared( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: bytes, ): @@ -1407,7 +1408,7 @@ def test_json_schema_untyped_int_bytes_declared( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: bytes, ): @@ -1459,7 +1460,7 @@ def test_json_schema_typed_bytes_int_declared( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: int, ): @@ -1511,7 +1512,7 @@ def test_json_schema_untyped_bytes_int_declared( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: int, ): @@ -1563,7 +1564,7 @@ def test_invalid_json( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_value: str, ): @@ -1622,7 +1623,7 @@ def test_json_schema_typed_invalid_type( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_type: str, ): @@ -1691,7 +1692,7 @@ def test_json_schema_untyped_invalid_type( cluster: clusterlib.ClusterLib, fund_script_guessing_game_v1: FundTupleT, fund_script_guessing_game_v2: FundTupleT, - cost_per_unit: plutus_common.ExecutionCost, + cost_per_unit: plutus_common.CostPerUnit, plutus_version: str, redeemer_type: str, ): diff --git a/cardano_node_tests/utils/dbsync_utils.py b/cardano_node_tests/utils/dbsync_utils.py index 85410411e..a09967035 100644 --- a/cardano_node_tests/utils/dbsync_utils.py +++ b/cardano_node_tests/utils/dbsync_utils.py @@ -936,13 +936,10 @@ def check_plutus_costs( def _get_float_pparam(pparam: tp.Any) -> float | None: + """Convert a rational parameter to a db-sync float, preserving missing values.""" if pparam is None: return None - if isinstance(pparam, dict): - numerator = pparam.get("numerator", 0) - denominator = pparam.get("denominator", 1) - return float(numerator / denominator) - return float(pparam) + return float(helpers.get_rational_pparam(pparam)) def map_params_to_db_convention(*, pparams: dict) -> dict[str, tp.Any]: @@ -974,7 +971,9 @@ def map_params_to_db_convention(*, pparams: dict) -> dict[str, tp.Any]: "treasury_growth_rate": _get_float_pparam(pparams.get("treasuryCut")), "min_pool_cost": pparams.get("minPoolCost"), "coins_per_utxo_size": pparams.get("utxoCostPerByte"), - "min_fee_ref_script_cost_per_byte": pparams.get("minFeeRefScriptCostPerByte"), + "min_fee_ref_script_cost_per_byte": _get_float_pparam( + pparams.get("minFeeRefScriptCostPerByte") + ), "price_mem": price_mem, "price_step": price_steps, # Technical proposals group diff --git a/cardano_node_tests/utils/governance_utils.py b/cardano_node_tests/utils/governance_utils.py index 987eac890..0040b6141 100644 --- a/cardano_node_tests/utils/governance_utils.py +++ b/cardano_node_tests/utils/governance_utils.py @@ -506,7 +506,7 @@ def _get_cvkey_hash(*, member: clusterlib.CCMember) -> str: _prev_action_ref(action_data), removed_members or [], added_members, - float(action_data.threshold), + helpers.get_rational_pparam(action_data.threshold), ], "tag": ActionTags.UPDATE_COMMITTEE.value, } @@ -553,7 +553,7 @@ def check_action_view( return_addr_vkey_hash: str = "", recv_addr_vkey_hash: str = "", ) -> None: - """Check `governance action view` output.""" + """Check `governance action view` output, comparing committee thresholds numerically.""" if not return_addr_vkey_hash: if action_data.deposit_return_stake_vkey_file: return_addr_vkey_hash = cluster_obj.g_stake_address.get_stake_vkey_hash( @@ -588,6 +588,10 @@ def check_action_view( action_view_out = cluster_obj.g_governance.action.view(action_file=action_data.action_file) + if isinstance(action_data, clusterlib.ActionUpdateCommittee): + contents = action_view_out["governance action"]["contents"] + contents[3] = helpers.get_rational_pparam(contents[3]) + assert action_view_out == expected_action_out, f"{action_view_out} != {expected_action_out}" diff --git a/cardano_node_tests/utils/helpers.py b/cardano_node_tests/utils/helpers.py index 45f3b9b9a..2afdb0364 100644 --- a/cardano_node_tests/utils/helpers.py +++ b/cardano_node_tests/utils/helpers.py @@ -1,6 +1,7 @@ import argparse import contextlib import datetime +import fractions import functools import hashlib import inspect @@ -448,6 +449,17 @@ def validate_dict_values( return errors +def get_rational_pparam(pparam: int | float | str | dict[str, int]) -> fractions.Fraction: + """Return a rational parameter as an exact fraction. + + Accept numbers, fraction strings, and numerator/denominator dictionaries. + Convert floats through their decimal strings to avoid binary rounding artifacts. + """ + if isinstance(pparam, dict): + return fractions.Fraction(pparam["numerator"], pparam["denominator"]) + return fractions.Fraction(str(pparam)) + + def get_pool_param(key: str, *, pool_params: dict) -> tp.Any: """Get pool parameter value from pool params dict.""" # Keys are prefixed with "sps" in cardano-node 10.6.0+ diff --git a/framework_tests/test_dbsync_utils.py b/framework_tests/test_dbsync_utils.py index eb0a7536c..f241e0675 100644 --- a/framework_tests/test_dbsync_utils.py +++ b/framework_tests/test_dbsync_utils.py @@ -16,6 +16,65 @@ IMAGE_HASH = "ab" * 32 +class TestRationalParamsMapping: + """Check numeric conversion of rational parameters for db-sync comparisons.""" + + @pytest.mark.parametrize( + ("param_name", "db_name"), + [ + ("monetaryExpansion", "monetary_expand_rate"), + ("treasuryCut", "treasury_growth_rate"), + ("poolPledgeInfluence", "influence"), + ("minFeeRefScriptCostPerByte", "min_fee_ref_script_cost_per_byte"), + ], + ) + @pytest.mark.parametrize( + ("value", "expected"), + [ + (0, 0.0), + (15, 15.0), + (0.5210625, 0.5210625), + ("8337/16000", 0.5210625), + ({"numerator": 8337, "denominator": 16000}, 0.5210625), + ({"numerator": 1, "denominator": 3}, 1 / 3), + (None, None), + ], + ) + def test_rational_parameter( + self, param_name: str, db_name: str, value: tp.Any, expected: float | None + ): + """Map equivalent rational representations to the db-sync numeric value.""" + mapped = dbsync_utils.map_params_to_db_convention(pparams={param_name: value}) + assert mapped[db_name] == expected + if expected is not None: + assert isinstance(mapped[db_name], float) + + def test_missing_parameters(self): + """Keep absent optional parameters as None.""" + mapped = dbsync_utils.map_params_to_db_convention(pparams={}) + assert mapped["min_fee_ref_script_cost_per_byte"] is None + assert mapped["price_mem"] is None + assert mapped["price_step"] is None + + def test_nested_rationals(self): + """Normalize execution prices and voting thresholds as well as top-level fields.""" + rational = {"numerator": 2, "denominator": 3} + mapped = dbsync_utils.map_params_to_db_convention( + pparams={ + "executionUnitPrices": {"priceMemory": rational, "priceSteps": rational}, + "dRepVotingThresholds": {"motionNoConfidence": rational}, + "poolVotingThresholds": {"motionNoConfidence": rational}, + } + ) + for name in ( + "price_mem", + "price_step", + "dvt_motion_no_confidence", + "pvt_motion_no_confidence", + ): + assert mapped[name] == 2 / 3 + + @pytest.fixture def check_image(monkeypatch: pytest.MonkeyPatch) -> tp.Callable[..., None]: """Check image metadata using stubbed db-sync rows and version information.""" diff --git a/framework_tests/test_governance_utils.py b/framework_tests/test_governance_utils.py new file mode 100644 index 000000000..7946588ae --- /dev/null +++ b/framework_tests/test_governance_utils.py @@ -0,0 +1,113 @@ +"""Unit tests for governance action checks without a running cluster.""" + +import dataclasses +import pathlib as pl +import typing as tp +from unittest import mock + +import pytest +from cardano_clusterlib import clusterlib + +from cardano_node_tests.utils import governance_utils + +CommitteeActionViewT = tuple[mock.Mock, clusterlib.ActionUpdateCommittee, dict[str, tp.Any]] + + +@pytest.fixture +def committee_action_view() -> CommitteeActionViewT: + """Return a committee action and its mocked CLI view, including non-threshold fields.""" + action = clusterlib.ActionUpdateCommittee( + action_file=pl.Path("committee.action"), + deposit_amt=100, + anchor_url="https://example.com/anchor", + anchor_data_hash="anchor-hash", + threshold="2/3", + add_cc_members=[clusterlib.CCMember(epoch=10, cold_vkey_hash="added")], + rem_cc_members=[clusterlib.CCMember(epoch=0, cold_vkey_hash="removed")], + prev_action_txid="previous-tx", + prev_action_ix=2, + deposit_return_stake_key_hash="return-key", + ) + view = { + "anchor": {"dataHash": "anchor-hash", "url": "https://example.com/anchor"}, + "deposit": 100, + "governance action": { + "tag": governance_utils.ActionTags.UPDATE_COMMITTEE.value, + "contents": [ + {"govActionIx": 2, "txId": "previous-tx"}, + [{"keyHash": "removed"}], + {"keyHash-added": 10}, + {"numerator": 2, "denominator": 3}, + ], + }, + "return address": {"credential": {"keyHash": "return-key"}, "network": "Testnet"}, + } + cluster = mock.Mock(spec=clusterlib.ClusterLib) + cluster.g_governance.action.view.return_value = view + return cluster, action, view + + +class TestCommitteeActionView: + """Compare committee thresholds numerically without weakening other action checks.""" + + @pytest.mark.parametrize( + ("threshold", "reported"), + [ + ("2/3", {"numerator": 2, "denominator": 3}), + ("2/3", {"numerator": 4, "denominator": 6}), + ("8337/16000", 0.5210625), + ("0.5210625", {"numerator": 8337, "denominator": 16000}), + ("0.8457565493", 0.8457565493), + ("0", 0), + ("1", 1), + ], + ) + def test_equivalent_thresholds( + self, + committee_action_view: CommitteeActionViewT, + threshold: str, + reported: int | float | dict[str, int], + ): + """Accept equal fraction strings, numeric thresholds, and rational objects.""" + cluster, action, view = committee_action_view + action = dataclasses.replace(action, threshold=threshold) + view["governance action"]["contents"][3] = reported + + governance_utils.check_action_view(cluster_obj=cluster, action_data=action) + + cluster.g_governance.action.view.assert_called_once_with(action_file=action.action_file) + + @pytest.mark.parametrize("reported", [0.5, {"numerator": 1, "denominator": 2}]) + def test_wrong_threshold( + self, + committee_action_view: CommitteeActionViewT, + reported: float | dict[str, int], + ): + """Reject a genuinely different threshold in either JSON representation.""" + cluster, action, view = committee_action_view + view["governance action"]["contents"][3] = reported + + with pytest.raises(AssertionError, match="!="): + governance_utils.check_action_view(cluster_obj=cluster, action_data=action) + + def test_wrong_committee_member( + self, + committee_action_view: CommitteeActionViewT, + ): + """Keep checking committee members after normalizing the threshold.""" + cluster, action, view = committee_action_view + view["governance action"]["contents"][2] = {"keyHash-unexpected": 10} + + with pytest.raises(AssertionError, match="!="): + governance_utils.check_action_view(cluster_obj=cluster, action_data=action) + + def test_wrong_deposit( + self, + committee_action_view: CommitteeActionViewT, + ): + """Keep checking the action envelope after normalizing the threshold.""" + cluster, action, view = committee_action_view + view["deposit"] = 200 + + with pytest.raises(AssertionError, match="!="): + governance_utils.check_action_view(cluster_obj=cluster, action_data=action) diff --git a/framework_tests/test_helpers.py b/framework_tests/test_helpers.py index 64771bb97..4b93a9db8 100644 --- a/framework_tests/test_helpers.py +++ b/framework_tests/test_helpers.py @@ -6,6 +6,7 @@ """ import argparse +import fractions import hashlib import inspect import json @@ -20,6 +21,53 @@ from cardano_node_tests.utils import helpers +class TestRationalPParam: + """Tests for rational protocol parameter normalization.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (0, fractions.Fraction(0)), + (1, fractions.Fraction(1)), + (0.1, fractions.Fraction(1, 10)), + (0.5210625, fractions.Fraction(8337, 16000)), + ("8337/16000", fractions.Fraction(8337, 16000)), + ("0.5210625", fractions.Fraction(8337, 16000)), + ({"numerator": 8337, "denominator": 16000}, fractions.Fraction(8337, 16000)), + ({"numerator": 2, "denominator": 3}, fractions.Fraction(2, 3)), + ({"numerator": 4, "denominator": 6}, fractions.Fraction(2, 3)), + ], + ) + def test_normalize( + self, value: int | float | str | dict[str, int], expected: fractions.Fraction + ): + """Normalize supported representations without approximating the rational value.""" + assert helpers.get_rational_pparam(value) == expected + + @pytest.mark.parametrize( + "value", + [ + {}, + {"numerator": 1}, + {"denominator": 3}, + ], + ) + def test_missing_components(self, value: dict[str, int]): + """Reject incomplete rational objects rather than inventing default components.""" + with pytest.raises(KeyError): + helpers.get_rational_pparam(value) + + def test_zero_denominator(self): + """Reject a rational with a zero denominator.""" + with pytest.raises(ZeroDivisionError): + helpers.get_rational_pparam({"numerator": 1, "denominator": 0}) + + def test_invalid_string(self): + """Reject strings that do not represent a rational value.""" + with pytest.raises(ValueError, match="Invalid literal"): + helpers.get_rational_pparam("not-a-rational") + + class TestChangeCwd: """Tests for `change_cwd`."""