From 50fb5310cd4601c03f724ac02e22c879199dd122 Mon Sep 17 00:00:00 2001 From: Martin Kourim Date: Fri, 13 Nov 2026 17:15:09 +0100 Subject: [PATCH 1/2] fix: compare rational pparams numerically Normalize decimal and numerator/denominator protocol parameter values to fractions before comparing them with proposed updates. Convert floats through their decimal strings to avoid binary rounding artifacts. This prevents false failures for equivalent values such as 8337/16000 and 0.5210625. Validation: make lint passed. E2E validation was not run because no Cardano node cluster was running. --- .../tests/tests_conway/test_pparam_update.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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..322beedb6 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,13 @@ 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 + """Compare rational parameters returned as decimals or numerator/denominator objects.""" if isinstance(pparam, dict): - exp_val = f"{pparam['numerator']}/{pparam['denominator']}" - return bool(update_proposal.value == exp_val) + pparam_value = fractions.Fraction(pparam["numerator"], pparam["denominator"]) + else: + # Preserve the JSON decimal value rather than the float's binary approximation. + pparam_value = fractions.Fraction(str(pparam)) + return fractions.Fraction(str(update_proposal.value)) == pparam_value def _check_max_tx_execution_units( From c53c7ea779efa4974892ee9592a8b2e2d8d16a35 Mon Sep 17 00:00:00 2001 From: Martin Kourim Date: Fri, 13 Nov 2026 17:38:18 +0100 Subject: [PATCH 2/2] fix: normalize rational protocol parameters Move rational parameter conversion into shared helpers and reuse it for governance thresholds, Leios quorum, Plutus prices and db-sync checks. Compare committee action thresholds numerically in both JSON forms. Separate unit prices from integer execution budgets with CostPerUnit, and document the purpose and fields of both data classes. Add framework regression tests and extend the committee action-view E2E check. Validation: 661 framework tests passed, with one expected failure. Linters passed. E2E validation was not run. --- cardano_node_tests/tests/plutus_common.py | 37 +++++- .../tests/tests_conway/conway_common.py | 16 +-- .../tests/tests_conway/test_committee.py | 17 ++- .../tests/tests_conway/test_pparam_update.py | 19 ++- .../tests/tests_dijkstra/test_leios_blocks.py | 4 +- .../tests_dijkstra/test_leios_pparams.py | 2 +- .../tests_plutus/test_spend_negative_raw.py | 31 ++--- cardano_node_tests/utils/dbsync_utils.py | 11 +- cardano_node_tests/utils/governance_utils.py | 8 +- cardano_node_tests/utils/helpers.py | 12 ++ framework_tests/test_dbsync_utils.py | 59 +++++++++ framework_tests/test_governance_utils.py | 113 ++++++++++++++++++ framework_tests/test_helpers.py | 48 ++++++++ 13 files changed, 318 insertions(+), 59 deletions(-) create mode 100644 framework_tests/test_governance_utils.py 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 322beedb6..ca395dd2d 100644 --- a/cardano_node_tests/tests/tests_conway/test_pparam_update.py +++ b/cardano_node_tests/tests/tests_conway/test_pparam_update.py @@ -178,12 +178,7 @@ def _check_w_denominator( update_proposal: clusterlib_utils.UpdateProposal, pparam: float | dict ) -> bool: """Compare rational parameters returned as decimals or numerator/denominator objects.""" - if isinstance(pparam, dict): - pparam_value = fractions.Fraction(pparam["numerator"], pparam["denominator"]) - else: - # Preserve the JSON decimal value rather than the float's binary approximation. - pparam_value = fractions.Fraction(str(pparam)) - return fractions.Fraction(str(update_proposal.value)) == pparam_value + return helpers.get_rational_pparam(update_proposal.value) == helpers.get_rational_pparam(pparam) def _check_max_tx_execution_units( @@ -369,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`."""