From 236df81c7d660581ebe5cedb10ebf72c0a5eded0 Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:26:56 +0900 Subject: [PATCH 1/3] Reorganize tests into domain-grouped subdirectories Restructure the flat tests/ layout into subdirectories that mirror the source tree (core/, contract/, chain_providers/, validator/, miner/). Split the mixed test_scale.py into contract/test_scale.py and chain_providers/test_subtensor.py. Extract shared swap and transaction fixtures into tests/helpers.py to eliminate duplication across modules. Add pytest-cov with term-missing and HTML report targets. --- pyproject.toml | 17 + tests/chain_providers/__init__.py | 0 .../test_bitcoin.py} | 2 +- tests/chain_providers/test_subtensor.py | 98 +++ tests/contract/__init__.py | 0 tests/contract/test_client.py | 688 ++++++++++++++++++ tests/{ => contract}/test_scale.py | 118 +-- tests/core/__init__.py | 0 tests/{ => core}/test_chains.py | 0 tests/{ => core}/test_commitments.py | 0 tests/{ => core}/test_rate.py | 50 +- tests/helpers.py | 44 ++ tests/miner/__init__.py | 0 tests/miner/test_fulfillment.py | 268 +++++++ tests/validator/__init__.py | 0 tests/validator/test_chain_verification.py | 146 ++++ tests/validator/test_fees.py | 62 ++ .../test_pending_confirms.py} | 2 + tests/validator/test_voting.py | 50 ++ uv.lock | 151 +++- 20 files changed, 1566 insertions(+), 130 deletions(-) create mode 100644 tests/chain_providers/__init__.py rename tests/{test_bitcoin_signing.py => chain_providers/test_bitcoin.py} (97%) create mode 100644 tests/chain_providers/test_subtensor.py create mode 100644 tests/contract/__init__.py create mode 100644 tests/contract/test_client.py rename tests/{ => contract}/test_scale.py (73%) create mode 100644 tests/core/__init__.py rename tests/{ => core}/test_chains.py (100%) rename tests/{ => core}/test_commitments.py (100%) rename tests/{ => core}/test_rate.py (83%) create mode 100644 tests/helpers.py create mode 100644 tests/miner/__init__.py create mode 100644 tests/miner/test_fulfillment.py create mode 100644 tests/validator/__init__.py create mode 100644 tests/validator/test_chain_verification.py create mode 100644 tests/validator/test_fees.py rename tests/{test_pending_confirm_queue.py => validator/test_pending_confirms.py} (97%) create mode 100644 tests/validator/test_voting.py diff --git a/pyproject.toml b/pyproject.toml index 3ca79d2..d6e29fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,9 +45,26 @@ alw = "allways.cli.main:main" [dependency-groups] dev = [ "pytest>=9.0.0", + "pytest-cov>=6.0.0", "ruff>=0.14.10", ] +[tool.pytest.ini_options] +addopts = "--cov=allways --cov-report=term-missing --cov-report=html:htmlcov" +testpaths = ["tests"] + +[tool.coverage.run] +source = ["allways"] +omit = ["allways/cli/*", "neurons/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "@abstractmethod", +] + # bitcoin-message-tool incorrectly lists pytest as a runtime dependency [tool.uv] override-dependencies = ["pytest>=9.0.0"] diff --git a/tests/chain_providers/__init__.py b/tests/chain_providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_bitcoin_signing.py b/tests/chain_providers/test_bitcoin.py similarity index 97% rename from tests/test_bitcoin_signing.py rename to tests/chain_providers/test_bitcoin.py index 52fe356..c872eac 100644 --- a/tests/test_bitcoin_signing.py +++ b/tests/chain_providers/test_bitcoin.py @@ -45,7 +45,7 @@ def test_empty(self): class TestBIP137SignVerify: - """Test BIP-137 sign/verify roundtrip using bitcoin-message-tool directly.""" + """BIP-137 sign/verify roundtrip using bitcoin-message-tool directly.""" def test_p2pkh_roundtrip(self): addr, _, sig = sign_message(TEST_WIF, 'p2pkh', TEST_MESSAGE, deterministic=True) diff --git a/tests/chain_providers/test_subtensor.py b/tests/chain_providers/test_subtensor.py new file mode 100644 index 0000000..a76d5da --- /dev/null +++ b/tests/chain_providers/test_subtensor.py @@ -0,0 +1,98 @@ +"""Tests for SubtensorProvider SCALE decoding and address validation.""" + +from unittest.mock import MagicMock + +from allways.chain_providers.subtensor import SubtensorProvider + + +class TestDecodeCompact: + def test_mode0_zero(self): + val, consumed = SubtensorProvider._decode_compact(bytes([0])) + assert val == 0 + assert consumed == 1 + + def test_mode0_max(self): + # 63 in mode 0: 63 << 2 = 252 + val, consumed = SubtensorProvider._decode_compact(bytes([252])) + assert val == 63 + assert consumed == 1 + + def test_mode1_64(self): + # Encode 64: (64 << 2) | 1 = 257 -> bytes [1, 1] (LE) + encoded = bytes([((64 << 2) | 1) & 0xFF, (64 << 2 | 1) >> 8]) + val, consumed = SubtensorProvider._decode_compact(encoded) + assert val == 64 + assert consumed == 2 + + def test_mode1_roundtrip_various(self): + for n in [64, 100, 1000, 16383]: + raw = (n << 2) | 1 + encoded = bytes([raw & 0xFF, (raw >> 8) & 0xFF]) + val, consumed = SubtensorProvider._decode_compact(encoded) + assert val == n, f'Failed for n={n}' + assert consumed == 2 + + def test_mode2_16384(self): + n = 16384 + raw = (n << 2) | 2 + encoded = raw.to_bytes(4, 'little') + val, consumed = SubtensorProvider._decode_compact(encoded) + assert val == n + assert consumed == 4 + + def test_mode2_large(self): + n = 100000 + raw = (n << 2) | 2 + encoded = raw.to_bytes(4, 'little') + val, consumed = SubtensorProvider._decode_compact(encoded) + assert val == n + assert consumed == 4 + + def test_mode3_big_integer(self): + # Mode 3: first byte = (num_extra_bytes - 4) << 2 | 3 + # For a number that fits in 5 bytes: n_bytes=5, first_byte = (5-4)<<2|3 = 7 + n = 2**32 + 1 # 4294967297, needs 5 bytes + n_bytes = (n.bit_length() + 7) // 8 + first_byte = ((n_bytes - 4) << 2) | 3 + encoded = bytes([first_byte]) + n.to_bytes(n_bytes, 'little') + val, consumed = SubtensorProvider._decode_compact(encoded) + assert val == n + assert consumed == 1 + n_bytes + + def test_empty_bytes(self): + val, consumed = SubtensorProvider._decode_compact(b'') + assert val == 0 + assert consumed == 0 + + def test_mode1_insufficient(self): + val, consumed = SubtensorProvider._decode_compact(bytes([0x01])) + assert val == 0 + assert consumed == 0 + + +class TestIsValidAddress: + def _provider(self): + return SubtensorProvider(MagicMock()) + + def test_valid_ss58(self): + p = self._provider() + # Typical 48-char SS58 address + addr = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' + assert p.is_valid_address(addr) is True + + def test_wrong_length(self): + p = self._provider() + assert p.is_valid_address('5GrwvaEF') is False + + def test_invalid_chars(self): + p = self._provider() + # Contains 0, O, I, l — invalid in base58 + assert p.is_valid_address('0' * 48) is False + + def test_empty(self): + p = self._provider() + assert p.is_valid_address('') is False + + def test_none(self): + p = self._provider() + assert p.is_valid_address(None) is False diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/test_client.py b/tests/contract/test_client.py new file mode 100644 index 0000000..f558c11 --- /dev/null +++ b/tests/contract/test_client.py @@ -0,0 +1,688 @@ +"""Tests for AllwaysContractClient — initialization, RPC layer, read/write methods. + +SCALE encoding/decoding primitives are covered in test_scale.py. +""" + +import struct +from unittest.mock import MagicMock, patch + +import pytest + +from allways.classes import Swap, SwapStatus +from allways.constants import MIN_BALANCE_FOR_TX_RAO +from allways.contract_client import ( + AllwaysContractClient, + ContractError, + ContractErrorKind, +) +from tests.helpers import make_swap + +_FAKE_SS58 = '5FTkUEhRmLPsALn4b7bJpVFhDQqohGbc6khnmA2aiYFLMZYP' +_FAKE_HEX = '0' * 64 # 32-byte all-zeros hex + + +def _make_client(): + """Full client with mocked subtensor, contract address set, initialized.""" + sub = MagicMock() + sub.substrate.ss58_decode.return_value = _FAKE_HEX + sub.substrate.ss58_encode.return_value = _FAKE_SS58 + client = AllwaysContractClient(contract_address=_FAKE_SS58, subtensor=sub) + return client, sub + + +def _build_rpc_ok(payload: bytes) -> dict: + """Build a minimal ContractExecResult RPC response wrapping payload bytes.""" + actual_data = b'\x00' + payload # lang error byte (ok) + payload + compact = bytes([len(actual_data) << 2]) # mode-0 compact length + flags = struct.pack(' dict: + """Build a ContractExecResult that signals a contract revert.""" + actual_data = bytes([0x00, 0x01, variant_idx]) # LangOk + Err + variant + compact = bytes([len(actual_data) << 2]) + flags = struct.pack(' MagicMock: + m = MagicMock() + m.value = {'data': {'free': free}} + return m + + +def _make_receipt(success: bool = True, hash_: str = '0xdeadbeef') -> MagicMock: + r = MagicMock() + r.is_success = success + r.extrinsic_hash = hash_ + r.error_message = 'some error' + return r + + +# ========================================================================= +# _ensure_initialized +# ========================================================================= + + +class TestEnsureInitialized: + def test_no_contract_address_raises(self): + # Bypass constructor fallback to CONTRACT_ADDRESS constant + client = AllwaysContractClient.__new__(AllwaysContractClient) + client.contract_address = '' + client.subtensor = MagicMock() + client._initialized = False + with pytest.raises(ContractError) as exc: + client._ensure_initialized() + assert exc.value.kind == ContractErrorKind.NOT_INITIALIZED + + def test_no_subtensor_raises(self): + client = AllwaysContractClient(contract_address=_FAKE_SS58, subtensor=None) + with pytest.raises(ContractError) as exc: + client._ensure_initialized() + assert exc.value.kind == ContractErrorKind.NOT_INITIALIZED + + def test_valid_sets_initialized(self): + client, _ = _make_client() + assert not client._initialized + client._ensure_initialized() + assert client._initialized + + def test_second_call_is_idempotent(self): + client, _ = _make_client() + client._ensure_initialized() + client._ensure_initialized() # should not raise + assert client._initialized + + +# ========================================================================= +# _decode_contract_error +# ========================================================================= + + +class TestDecodeContractError: + def test_known_variant(self): + # variant 3 = AlreadyVoted + payload = bytes([0x00, 0x01, 3]) + r = b'\x00' * 16 + payload + err = AllwaysContractClient._decode_contract_error('confirm_swap', r, 16, 3) + assert err.kind == ContractErrorKind.CONTRACT_REJECTED + assert 'AlreadyVoted' in str(err) + + def test_unknown_variant(self): + payload = bytes([0x00, 0x01, 99]) + r = b'\x00' * 16 + payload + err = AllwaysContractClient._decode_contract_error('confirm_swap', r, 16, 3) + assert 'unknown error variant' in str(err) + + def test_malformed_payload_fallback(self): + payload = bytes([0xFF]) + r = b'\x00' * 16 + payload + err = AllwaysContractClient._decode_contract_error('get_swap', r, 16, 1) + assert err.kind == ContractErrorKind.CONTRACT_REJECTED + + def test_too_short_payload(self): + payload = bytes([0x00, 0x01]) # only 2 bytes, need >= 3 + r = b'\x00' * 16 + payload + err = AllwaysContractClient._decode_contract_error('get_swap', r, 16, 2) + assert 'contract rejected' in str(err) + + +# ========================================================================= +# _raw_contract_read +# ========================================================================= + + +class TestRawContractRead: + def test_unknown_method_returns_none(self): + client, _ = _make_client() + assert client._raw_contract_read('nonexistent_method') is None + + def test_rpc_returns_empty_result(self): + client, sub = _make_client() + sub.substrate.rpc_request.return_value = {} + assert client._raw_contract_read('get_next_swap_id') is None + + def test_rpc_response_too_short(self): + client, sub = _make_client() + sub.substrate.rpc_request.return_value = {'result': '0x' + 'aa' * 10} + assert client._raw_contract_read('get_next_swap_id') is None + + def test_result_not_ok(self): + """r[10] != 0x00 → return None.""" + client, sub = _make_client() + r = b'\x00' * 10 + b'\x01' + b'\x00' * 10 # byte 10 = 0x01 (Err) + raw = b'\x00' * 16 + r + sub.substrate.rpc_request.return_value = {'result': '0x' + raw.hex()} + assert client._raw_contract_read('get_next_swap_id') is None + + def test_mode0_success(self): + client, sub = _make_client() + payload = struct.pack('> 8]) + flags = struct.pack('> 64) + self._setup_raw(client, sub, payload) + assert client._read_u128('get_min_collateral') == val + + def test_read_bool_true(self): + client, sub = _make_client() + self._setup_raw(client, sub, b'\x01') + client.subtensor.substrate.ss58_decode.return_value = _FAKE_HEX + assert client._read_bool('get_miner_active', {'hotkey': _FAKE_SS58}) is True + + def test_read_bool_no_response_raises(self): + client, sub = _make_client() + sub.substrate.rpc_request.return_value = {} + with pytest.raises(ContractError): + client._read_bool('get_halted') + + def test_read_account_id_no_response_raises(self): + client, sub = _make_client() + sub.substrate.rpc_request.return_value = {} + with pytest.raises(ContractError): + client._read_account_id('get_owner') + + def test_read_account_id_success(self): + client, sub = _make_client() + self._setup_raw(client, sub, b'\xAB' * 32) + sub.substrate.ss58_encode.return_value = '5Fowner' + assert client._read_account_id('get_owner') == '5Fowner' + + +# ========================================================================= +# _read_option_swap +# ========================================================================= + + +class TestReadOptionSwap: + def test_none_data_returns_none(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=None): + assert client._read_option_swap('get_swap', {'swap_id': 1}) is None + + def test_option_none_discriminant(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=b'\x00'): + assert client._read_option_swap('get_swap', {'swap_id': 1}) is None + + def test_option_some_calls_decode(self): + client, _ = _make_client() + fake_swap = MagicMock(spec=Swap) + with patch.object(client, '_raw_contract_read', return_value=b'\x01' + b'\x00' * 50): + with patch.object(client, '_decode_swap_data', return_value=fake_swap): + result = client._read_option_swap('get_swap', {'swap_id': 1}) + assert result is fake_swap + + def test_unknown_discriminant_returns_none(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=b'\x02'): + assert client._read_option_swap('get_swap', {'swap_id': 1}) is None + + +# ========================================================================= +# _read_result_option_swap +# ========================================================================= + + +class TestReadResultOptionSwap: + def test_none_data_returns_none(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=None): + assert client._read_result_option_swap('get_swap') is None + + def test_result_err_returns_none(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=b'\x01\x02'): + assert client._read_result_option_swap('get_swap') is None + + def test_result_ok_option_none(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=b'\x00\x00'): + assert client._read_result_option_swap('get_swap') is None + + def test_result_ok_option_some(self): + client, _ = _make_client() + fake_swap = MagicMock(spec=Swap) + with patch.object(client, '_raw_contract_read', return_value=b'\x00\x01' + b'\x00' * 50): + with patch.object(client, '_decode_swap_data', return_value=fake_swap): + result = client._read_result_option_swap('get_swap') + assert result is fake_swap + + +# ========================================================================= +# _read_result_u128 +# ========================================================================= + + +class TestReadResultU128: + def test_none_data_raises(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=None): + with pytest.raises(ContractError) as exc: + client._read_result_u128('get_collateral', {'hotkey': _FAKE_SS58}) + assert exc.value.kind == ContractErrorKind.RPC_FAILURE + + def test_result_err_known_variant_raises(self): + client, _ = _make_client() + # variant 5 = ZeroAmount + with patch.object(client, '_raw_contract_read', return_value=b'\x01\x05'): + with pytest.raises(ContractError) as exc: + client._read_result_u128('get_collateral', {'hotkey': _FAKE_SS58}) + assert 'ZeroAmount' in str(exc.value) + + def test_result_err_no_variant_raises(self): + client, _ = _make_client() + with patch.object(client, '_raw_contract_read', return_value=b'\x01'): + with pytest.raises(ContractError): + client._read_result_u128('get_collateral', {'hotkey': _FAKE_SS58}) + + def test_result_ok_returns_value(self): + client, _ = _make_client() + val = 500_000_000 + payload = b'\x00' + struct.pack(' bytes [1, 1] (LE) - encoded = bytes([((64 << 2) | 1) & 0xFF, (64 << 2 | 1) >> 8]) - val, consumed = SubtensorProvider._decode_compact(encoded) - assert val == 64 - assert consumed == 2 - - def test_mode1_roundtrip_various(self): - for n in [64, 100, 1000, 16383]: - raw = (n << 2) | 1 - encoded = bytes([raw & 0xFF, (raw >> 8) & 0xFF]) - val, consumed = SubtensorProvider._decode_compact(encoded) - assert val == n, f'Failed for n={n}' - assert consumed == 2 - - def test_mode2_16384(self): - n = 16384 - raw = (n << 2) | 2 - encoded = raw.to_bytes(4, 'little') - val, consumed = SubtensorProvider._decode_compact(encoded) - assert val == n - assert consumed == 4 - - def test_mode2_large(self): - n = 100000 - raw = (n << 2) | 2 - encoded = raw.to_bytes(4, 'little') - val, consumed = SubtensorProvider._decode_compact(encoded) - assert val == n - assert consumed == 4 - - def test_mode3_big_integer(self): - # Mode 3: first byte = (num_extra_bytes - 4) << 2 | 3 - # For a number that fits in 5 bytes: n_bytes=5, first_byte = (5-4)<<2|3 = 7 - n = 2**32 + 1 # 4294967297, needs 5 bytes - n_bytes = (n.bit_length() + 7) // 8 - first_byte = ((n_bytes - 4) << 2) | 3 - encoded = bytes([first_byte]) + n.to_bytes(n_bytes, 'little') - val, consumed = SubtensorProvider._decode_compact(encoded) - assert val == n - assert consumed == 1 + n_bytes - - def test_empty_bytes(self): - val, consumed = SubtensorProvider._decode_compact(b'') - assert val == 0 - assert consumed == 0 - - def test_mode1_insufficient(self): - val, consumed = SubtensorProvider._decode_compact(bytes([0x01])) - assert val == 0 - assert consumed == 0 - - -# ========================================================================= -# SubtensorProvider.is_valid_address tests -# ========================================================================= - - -class TestIsValidAddress: - def _provider(self): - return SubtensorProvider(MagicMock()) - - def test_valid_ss58(self): - p = self._provider() - # Typical 48-char SS58 address - addr = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' - assert p.is_valid_address(addr) is True - - def test_wrong_length(self): - p = self._provider() - assert p.is_valid_address('5GrwvaEF') is False - - def test_invalid_chars(self): - p = self._provider() - # Contains 0, O, I, l — invalid in base58 - assert p.is_valid_address('0' * 48) is False - - def test_empty(self): - p = self._provider() - assert p.is_valid_address('') is False - - def test_none(self): - p = self._provider() - assert p.is_valid_address(None) is False diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_chains.py b/tests/core/test_chains.py similarity index 100% rename from tests/test_chains.py rename to tests/core/test_chains.py diff --git a/tests/test_commitments.py b/tests/core/test_commitments.py similarity index 100% rename from tests/test_commitments.py rename to tests/core/test_commitments.py diff --git a/tests/test_rate.py b/tests/core/test_rate.py similarity index 83% rename from tests/test_rate.py rename to tests/core/test_rate.py index 538720a..33a96f0 100644 --- a/tests/test_rate.py +++ b/tests/core/test_rate.py @@ -2,8 +2,9 @@ from decimal import Decimal +from allways.classes import Swap from allways.constants import BTC_TO_SAT, RATE_PRECISION, TAO_TO_RAO -from allways.utils.rate import apply_fee_deduction, calculate_dest_amount +from allways.utils.rate import apply_fee_deduction, calculate_dest_amount, expected_swap_amounts # Chain decimals TAO_DEC = 9 @@ -237,3 +238,50 @@ def test_fee_plus_user_equals_total(self): user = tao_amount - fee assert fee + user <= tao_amount assert tao_amount - fee - user < self.FEE_DIVISOR + + +def _make_swap(source_chain, dest_chain, source_amount, rate) -> Swap: + return Swap( + id=1, + user_hotkey='5Fuser', + miner_hotkey='5Fminer', + source_chain=source_chain, + dest_chain=dest_chain, + source_amount=source_amount, + dest_amount=0, + tao_amount=0, + user_source_address='bc1quser', + user_dest_address='5Fdest', + rate=rate, + ) + + +class TestExpectedSwapAmounts: + """expected_swap_amounts — integrates canonical_pair, calculate_dest_amount, apply_fee_deduction.""" + + FEE_DIVISOR = 100 + + def test_btc_to_tao_forward(self): + # 0.01 BTC @ 350 → 3.5 TAO raw → 3.465 TAO after 1% fee + swap = _make_swap('btc', 'tao', BTC_TO_SAT // 100, '350') + raw, user = expected_swap_amounts(swap, self.FEE_DIVISOR) + assert raw == 3_500_000_000 + assert user == 3_465_000_000 + + def test_tao_to_btc_reverse(self): + # 350 TAO → 1 BTC raw → 0.99 BTC after 1% fee + swap = _make_swap('tao', 'btc', 350 * TAO_TO_RAO, '350') + raw, user = expected_swap_amounts(swap, self.FEE_DIVISOR) + assert raw == BTC_TO_SAT + assert user == BTC_TO_SAT - BTC_TO_SAT // self.FEE_DIVISOR + + def test_zero_rate_returns_zeros(self): + swap = _make_swap('btc', 'tao', BTC_TO_SAT, '0') + raw, user = expected_swap_amounts(swap, self.FEE_DIVISOR) + assert raw == 0 + assert user == 0 + + def test_raw_and_user_consistency(self): + swap = _make_swap('btc', 'tao', BTC_TO_SAT // 100, '345') + raw, user = expected_swap_amounts(swap, self.FEE_DIVISOR) + assert user == apply_fee_deduction(raw, self.FEE_DIVISOR) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..684dd56 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,44 @@ +"""Shared factory functions used across multiple test modules.""" + +from allways.chain_providers.base import TransactionInfo +from allways.classes import Swap, SwapStatus + + +def make_swap(**overrides) -> Swap: + """Build a Swap with sensible BTC→TAO defaults. Override any field as needed.""" + defaults = dict( + id=1, + user_hotkey='5Fuser', + miner_hotkey='5Fminer', + source_chain='btc', + dest_chain='tao', + source_amount=1_000_000, # 0.01 BTC in sat + dest_amount=3_465_000_000, # 3.465 TAO (rate=350, 1% fee) + tao_amount=3_500_000_000, + user_source_address='bc1quser', + user_dest_address='5Fdest', + miner_source_address='bc1qminer', + miner_dest_address='5Fminer_dest', + rate='350', + source_tx_hash='deadbeef' * 8, + dest_tx_hash='cafebabe' * 8, + source_tx_block=100, + timeout_block=9999, + status=SwapStatus.ACTIVE, + ) + defaults.update(overrides) + return Swap(**defaults) + + +def make_confirmed_tx(**overrides) -> TransactionInfo: + """Build a confirmed TransactionInfo. Override any field as needed.""" + defaults = dict( + tx_hash='deadbeef' * 8, + confirmed=True, + sender='bc1qsender', + recipient='bc1qminer', + amount=1_000_000, + confirmations=6, + ) + defaults.update(overrides) + return TransactionInfo(**defaults) diff --git a/tests/miner/__init__.py b/tests/miner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/miner/test_fulfillment.py b/tests/miner/test_fulfillment.py new file mode 100644 index 0000000..631d4a0 --- /dev/null +++ b/tests/miner/test_fulfillment.py @@ -0,0 +1,268 @@ +"""Tests for allways.miner.fulfillment — SwapFulfiller.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from allways.contract_client import ContractError, ContractErrorKind +from allways.miner.fulfillment import SwapFulfiller +from tests.helpers import make_confirmed_tx, make_swap + + +def _make_fulfiller(providers=None, subtensor_block=100, sent_cache_path=None): + client = MagicMock() + wallet = MagicMock() + subtensor = MagicMock() + subtensor.get_current_block.return_value = subtensor_block + return SwapFulfiller( + contract_client=client, + chain_providers=providers or {}, + wallet=wallet, + subtensor=subtensor, + netuid=7, + fee_divisor=100, + sent_cache_path=sent_cache_path, + ), client, wallet, subtensor + + +# ─── Sent cache ────────────────────────────────────────────────────────────── + + +class TestSentCache: + def test_load_no_path(self): + fulfiller, *_ = _make_fulfiller() + assert fulfiller._sent == {} + + def test_load_missing_file(self, tmp_path): + path = tmp_path / 'sent.json' + fulfiller, *_ = _make_fulfiller(sent_cache_path=path) + assert fulfiller._sent == {} + + def test_load_valid_file(self, tmp_path): + path = tmp_path / 'sent.json' + path.write_text(json.dumps({'5': ['abc123', 500], '7': ['def456', 600]})) + fulfiller, *_ = _make_fulfiller(sent_cache_path=path) + assert fulfiller._sent == {5: ('abc123', 500), 7: ('def456', 600)} + + def test_load_corrupt_file_is_ignored(self, tmp_path): + path = tmp_path / 'sent.json' + path.write_text('not json {{') + fulfiller, *_ = _make_fulfiller(sent_cache_path=path) + assert fulfiller._sent == {} + + def test_save_no_path_is_noop(self): + fulfiller, *_ = _make_fulfiller() + fulfiller._sent[1] = ('txhash', 42) + fulfiller._save_sent_cache() # should not raise + + def test_save_and_reload(self, tmp_path): + path = tmp_path / 'sent.json' + fulfiller, *_ = _make_fulfiller(sent_cache_path=path) + fulfiller._sent[3] = ('txabc', 77) + fulfiller._save_sent_cache() + assert path.exists() + data = json.loads(path.read_text()) + assert data == {'3': ['txabc', 77]} + + +# ─── cleanup_stale_sends ───────────────────────────────────────────────────── + + +class TestCleanupStaleSends: + def test_removes_inactive_entries(self): + fulfiller, *_ = _make_fulfiller() + fulfiller._sent = {1: ('tx1', 10), 2: ('tx2', 20), 3: ('tx3', 30)} + fulfiller.cleanup_stale_sends(active_swap_ids={2}) + assert set(fulfiller._sent.keys()) == {2} + + def test_keeps_all_when_all_active(self): + fulfiller, *_ = _make_fulfiller() + fulfiller._sent = {1: ('tx1', 10), 2: ('tx2', 20)} + fulfiller.cleanup_stale_sends(active_swap_ids={1, 2}) + assert set(fulfiller._sent.keys()) == {1, 2} + + def test_empty_active_clears_all(self): + fulfiller, *_ = _make_fulfiller() + fulfiller._sent = {1: ('tx1', 10)} + fulfiller.cleanup_stale_sends(active_swap_ids=set()) + assert fulfiller._sent == {} + + +# ─── _verify_swap_safety ───────────────────────────────────────────────────── + + +class TestVerifySwapSafety: + def test_timed_out_returns_none(self): + fulfiller, *_ = _make_fulfiller(subtensor_block=10000) + swap = make_swap(timeout_block=9999) # already past + assert fulfiller._verify_swap_safety(swap) is None + + def test_at_timeout_block_returns_none(self): + fulfiller, *_ = _make_fulfiller(subtensor_block=9999) + swap = make_swap(timeout_block=9999) # current >= timeout + assert fulfiller._verify_swap_safety(swap) is None + + def test_missing_rate_returns_none(self): + fulfiller, *_ = _make_fulfiller() + swap = make_swap(rate='') + assert fulfiller._verify_swap_safety(swap) is None + + def test_missing_miner_source_address_returns_none(self): + fulfiller, *_ = _make_fulfiller() + swap = make_swap(miner_source_address='') + assert fulfiller._verify_swap_safety(swap) is None + + def test_zero_rate_returns_none(self): + fulfiller, *_ = _make_fulfiller() + swap = make_swap(rate='0') + assert fulfiller._verify_swap_safety(swap) is None + + def test_valid_swap_returns_amounts(self): + fulfiller, *_ = _make_fulfiller(subtensor_block=100) + swap = make_swap(rate='350', source_amount=1_000_000, timeout_block=9999) + result = fulfiller._verify_swap_safety(swap) + assert result is not None + dest_amount, source_addr = result + # 0.01 BTC @ 350 = 3.5 TAO, minus 1% fee = 3.465 TAO + assert dest_amount == 3_465_000_000 + assert source_addr == 'bc1qminer' + + +# ─── verify_user_sent_funds ─────────────────────────────────────────────────── + + +class TestVerifyUserSentFunds: + def test_no_provider_returns_false(self): + fulfiller, *_ = _make_fulfiller(providers={}) + swap = make_swap() + assert fulfiller.verify_user_sent_funds(swap, 'bc1qminer') is False + + def test_no_source_tx_hash_returns_false(self): + provider = MagicMock() + fulfiller, *_ = _make_fulfiller(providers={'btc': provider}) + swap = make_swap(source_tx_hash='') + assert fulfiller.verify_user_sent_funds(swap, 'bc1qminer') is False + provider.verify_transaction.assert_not_called() + + def test_provider_returns_none(self): + provider = MagicMock() + provider.verify_transaction.return_value = None + fulfiller, *_ = _make_fulfiller(providers={'btc': provider}) + swap = make_swap() + assert fulfiller.verify_user_sent_funds(swap, 'bc1qminer') is False + + def test_unconfirmed_returns_false(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx(confirmed=False) + fulfiller, *_ = _make_fulfiller(providers={'btc': provider}) + swap = make_swap() + assert fulfiller.verify_user_sent_funds(swap, 'bc1qminer') is False + + def test_confirmed_returns_true(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx(confirmed=True) + fulfiller, *_ = _make_fulfiller(providers={'btc': provider}) + swap = make_swap() + assert fulfiller.verify_user_sent_funds(swap, 'bc1qminer') is True + + def test_provider_exception_returns_false(self): + provider = MagicMock() + provider.verify_transaction.side_effect = ConnectionError('node offline') + fulfiller, *_ = _make_fulfiller(providers={'btc': provider}) + swap = make_swap() + assert fulfiller.verify_user_sent_funds(swap, 'bc1qminer') is False + + +# ─── send_dest_funds ────────────────────────────────────────────────────────── + + +class TestSendDestFunds: + def test_no_provider_returns_none(self): + fulfiller, *_ = _make_fulfiller(providers={}) + swap = make_swap() + assert fulfiller.send_dest_funds(swap, 3_465_000_000) is None + + def test_tao_dest_uses_wallet_key(self): + tao_provider = MagicMock() + tao_provider.send_amount.return_value = ('tao_tx', 200) + fulfiller, _, wallet, _ = _make_fulfiller(providers={'tao': tao_provider}) + swap = make_swap(source_chain='btc', dest_chain='tao') + result = fulfiller.send_dest_funds(swap, 3_465_000_000) + assert result == ('tao_tx', 200) + tao_provider.send_amount.assert_called_once_with('5Fdest', 3_465_000_000, key=wallet, from_address=None) + + def test_send_fails_returns_none(self): + tao_provider = MagicMock() + tao_provider.send_amount.return_value = None + fulfiller, *_ = _make_fulfiller(providers={'tao': tao_provider}) + swap = make_swap(dest_chain='tao') + assert fulfiller.send_dest_funds(swap, 3_465_000_000) is None + + +# ─── process_swap ───────────────────────────────────────────────────────────── + + +class TestProcessSwap: + def _setup(self, subtensor_block=100): + btc_provider = MagicMock() + tao_provider = MagicMock() + btc_provider.verify_transaction.return_value = make_confirmed_tx() + tao_provider.send_amount.return_value = ('dest_tx_abc', 101) + fulfiller, client, wallet, subtensor = _make_fulfiller( + providers={'btc': btc_provider, 'tao': tao_provider}, + subtensor_block=subtensor_block, + ) + return fulfiller, client, btc_provider, tao_provider + + def test_safety_check_fails_returns_false(self): + fulfiller, *_ = _make_fulfiller(subtensor_block=99999) + swap = make_swap(timeout_block=100) # already timed out + assert fulfiller.process_swap(swap) is False + + def test_source_funds_not_confirmed_returns_false(self): + fulfiller, client, btc, tao = self._setup() + btc.verify_transaction.return_value = None # not confirmed + swap = make_swap() + assert fulfiller.process_swap(swap) is False + client.mark_fulfilled.assert_not_called() + + def test_send_fails_returns_false(self): + fulfiller, client, btc, tao = self._setup() + tao.send_amount.return_value = None + swap = make_swap() + assert fulfiller.process_swap(swap) is False + client.mark_fulfilled.assert_not_called() + + def test_mark_fulfilled_contract_error_returns_false(self): + fulfiller, client, btc, tao = self._setup() + client.mark_fulfilled.side_effect = ContractError(ContractErrorKind.CONTRACT_REJECTED, 'already fulfilled') + swap = make_swap() + assert fulfiller.process_swap(swap) is False + + def test_full_success_returns_true(self): + fulfiller, client, btc, tao = self._setup() + swap = make_swap() + assert fulfiller.process_swap(swap) is True + client.mark_fulfilled.assert_called_once() + + def test_cached_send_skips_provider(self): + fulfiller, client, btc, tao = self._setup() + swap = make_swap() + fulfiller._sent[swap.id] = ('cached_tx', 55) + assert fulfiller.process_swap(swap) is True + tao.send_amount.assert_not_called() + client.mark_fulfilled.assert_called_once_with( + wallet=fulfiller.wallet, + swap_id=swap.id, + dest_tx_hash='cached_tx', + dest_amount=3_465_000_000, + dest_tx_block=55, + ) + + def test_successful_send_clears_cache(self): + fulfiller, client, btc, tao = self._setup() + swap = make_swap() + fulfiller.process_swap(swap) + assert swap.id not in fulfiller._sent diff --git a/tests/validator/__init__.py b/tests/validator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/validator/test_chain_verification.py b/tests/validator/test_chain_verification.py new file mode 100644 index 0000000..13ae952 --- /dev/null +++ b/tests/validator/test_chain_verification.py @@ -0,0 +1,146 @@ +"""Tests for allways.validator.chain_verification — SwapVerifier.""" + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from allways.validator.chain_verification import SwapVerifier +from tests.helpers import make_confirmed_tx, make_swap + + +def _make_verifier(providers=None): + subtensor = MagicMock() + verifier = SwapVerifier( + chain_providers=providers or {}, + subtensor=subtensor, + netuid=7, + fee_divisor=100, + ) + return verifier + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# ─── _verify_tx ────────────────────────────────────────────────────────────── + + +class TestVerifyTx: + def test_no_provider_returns_false(self): + verifier = _make_verifier(providers={}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) is False + + def test_empty_tx_hash_returns_false(self): + provider = MagicMock() + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', '', 'bc1qminer', 1_000_000) is False + provider.verify_transaction.assert_not_called() + + def test_provider_returns_none(self): + provider = MagicMock() + provider.verify_transaction.return_value = None + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) is False + + def test_unconfirmed_tx_returns_false(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx(confirmed=False, confirmations=2) + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) is False + + def test_unconfirmed_logs_only_once_per_conf_change(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx(confirmed=False, confirmations=2) + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) + verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) + assert verifier._last_logged_confs.get('1:btc') == 2 + + def test_sender_mismatch_returns_false(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx(sender='bc1qwrong') + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + result = verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000, expected_sender='bc1qexpected') + assert result is False + + def test_confirmed_no_sender_check_returns_true(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx() + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) is True + + def test_confirmed_correct_sender_returns_true(self): + provider = MagicMock() + provider.verify_transaction.return_value = make_confirmed_tx(sender='bc1qsender') + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000, expected_sender='bc1qsender') is True + + def test_provider_exception_returns_false(self): + provider = MagicMock() + provider.verify_transaction.side_effect = ConnectionError('node down') + verifier = _make_verifier({'btc': provider}) + swap = make_swap() + assert verifier._verify_tx(swap, 'btc', 'deadbeef', 'bc1qminer', 1_000_000) is False + + +# ─── is_swap_complete ───────────────────────────────────────────────────────── + + +class TestIsSwapComplete: + def test_missing_rate_returns_false(self): + verifier = _make_verifier() + swap = make_swap(rate='') + assert _run(verifier.is_swap_complete(swap)) is False + + def test_missing_miner_source_address_returns_false(self): + verifier = _make_verifier() + swap = make_swap(miner_source_address='') + assert _run(verifier.is_swap_complete(swap)) is False + + def test_zero_rate_returns_false(self): + verifier = _make_verifier() + swap = make_swap(rate='0') + assert _run(verifier.is_swap_complete(swap)) is False + + def test_dest_amount_mismatch_returns_false(self): + verifier = _make_verifier() + swap = make_swap(dest_amount=9_999_999_999) + assert _run(verifier.is_swap_complete(swap)) is False + + def test_source_verify_fails_returns_false(self): + btc_provider = MagicMock() + tao_provider = MagicMock() + btc_provider.verify_transaction.return_value = None # source fails + tao_provider.verify_transaction.return_value = make_confirmed_tx() + verifier = _make_verifier({'btc': btc_provider, 'tao': tao_provider}) + # rate=350, source=1_000_000 sat → raw=3_500_000_000, user=3_465_000_000 + swap = make_swap(dest_amount=3_465_000_000) + assert _run(verifier.is_swap_complete(swap)) is False + + def test_dest_verify_fails_returns_false(self): + btc_provider = MagicMock() + tao_provider = MagicMock() + btc_provider.verify_transaction.return_value = make_confirmed_tx() + tao_provider.verify_transaction.return_value = None # dest fails + verifier = _make_verifier({'btc': btc_provider, 'tao': tao_provider}) + swap = make_swap(dest_amount=3_465_000_000) + assert _run(verifier.is_swap_complete(swap)) is False + + def test_both_verified_returns_true(self): + btc_provider = MagicMock() + tao_provider = MagicMock() + btc_provider.verify_transaction.return_value = make_confirmed_tx() + tao_provider.verify_transaction.return_value = make_confirmed_tx(sender='5Fminer_dest') + verifier = _make_verifier({'btc': btc_provider, 'tao': tao_provider}) + swap = make_swap(dest_amount=3_465_000_000) + assert _run(verifier.is_swap_complete(swap)) is True diff --git a/tests/validator/test_fees.py b/tests/validator/test_fees.py new file mode 100644 index 0000000..1e3db55 --- /dev/null +++ b/tests/validator/test_fees.py @@ -0,0 +1,62 @@ +"""Tests for allways.validator.utils.fees.""" + +from allways.classes import Swap, SwapStatus +from allways.constants import TAO_TO_RAO +from allways.validator.utils.fees import swap_fee_rao, windowed_fees_rao + + +def _make_swap(swap_id: int, tao_amount: int, status: SwapStatus = SwapStatus.COMPLETED) -> Swap: + """Fee-focused swap builder with tao_amount as the key variable.""" + return Swap( + id=swap_id, + user_hotkey='user', + miner_hotkey='miner', + source_chain='btc', + dest_chain='tao', + source_amount=1_000_000, + dest_amount=tao_amount, + tao_amount=tao_amount, + user_source_address='bc1quser', + user_dest_address='5Fuser', + status=status, + ) + + +class TestSwapFeeRao: + def test_default_divisor(self): + swap = _make_swap(1, 100 * TAO_TO_RAO) + assert swap_fee_rao(swap) == TAO_TO_RAO # 1 TAO fee on 100 TAO swap + + def test_custom_divisor(self): + swap = _make_swap(1, 200 * TAO_TO_RAO) + assert swap_fee_rao(swap, fee_divisor=200) == TAO_TO_RAO # 1 TAO fee + + def test_small_amount_floors_to_zero(self): + swap = _make_swap(1, tao_amount=50) + assert swap_fee_rao(swap, fee_divisor=100) == 0 # 50 // 100 == 0 + + def test_integer_floor_division(self): + swap = _make_swap(1, tao_amount=199) + assert swap_fee_rao(swap, fee_divisor=100) == 1 # 199 // 100 == 1 + + +class TestWindowedFeesRao: + def test_empty_window(self): + assert windowed_fees_rao([]) == 0 + + def test_all_completed(self): + swaps = [_make_swap(i, TAO_TO_RAO * 100, SwapStatus.COMPLETED) for i in range(3)] + assert windowed_fees_rao(swaps) == 3 * TAO_TO_RAO # 3 × 1 TAO fee + + def test_only_completed_counted(self): + swaps = [ + _make_swap(1, TAO_TO_RAO * 100, SwapStatus.COMPLETED), + _make_swap(2, TAO_TO_RAO * 100, SwapStatus.ACTIVE), + _make_swap(3, TAO_TO_RAO * 100, SwapStatus.FULFILLED), + _make_swap(4, TAO_TO_RAO * 100, SwapStatus.TIMED_OUT), + ] + assert windowed_fees_rao(swaps) == TAO_TO_RAO # only swap 1 + + def test_custom_divisor(self): + swaps = [_make_swap(i, TAO_TO_RAO * 100, SwapStatus.COMPLETED) for i in range(2)] + assert windowed_fees_rao(swaps, fee_divisor=50) == 2 * 2 * TAO_TO_RAO # 2 × 2 TAO fee diff --git a/tests/test_pending_confirm_queue.py b/tests/validator/test_pending_confirms.py similarity index 97% rename from tests/test_pending_confirm_queue.py rename to tests/validator/test_pending_confirms.py index c597275..90d5c8f 100644 --- a/tests/test_pending_confirm_queue.py +++ b/tests/validator/test_pending_confirms.py @@ -1,3 +1,5 @@ +"""Tests for allways.validator.pending_confirms — PendingConfirmQueue persistence and thread safety.""" + import threading import time from dataclasses import replace diff --git a/tests/validator/test_voting.py b/tests/validator/test_voting.py new file mode 100644 index 0000000..695039d --- /dev/null +++ b/tests/validator/test_voting.py @@ -0,0 +1,50 @@ +"""Tests for allways.validator.voting — SwapVoter.""" + +from unittest.mock import MagicMock + +import pytest + +from allways.validator.voting import SwapVoter + + +def _make_voter(): + client = MagicMock() + wallet = MagicMock() + return SwapVoter(contract_client=client, wallet=wallet), client, wallet + + +class TestConfirmSwap: + def test_success_returns_true(self): + voter, client, wallet = _make_voter() + assert voter.confirm_swap(42) is True + client.confirm_swap.assert_called_once_with(wallet=wallet, swap_id=42) + + def test_contract_error_returns_false(self): + voter, client, _ = _make_voter() + client.confirm_swap.side_effect = Exception('contract reverted') + assert voter.confirm_swap(42) is False + + +class TestTimeoutSwap: + def test_success_returns_true(self): + voter, client, wallet = _make_voter() + assert voter.timeout_swap(7) is True + client.timeout_swap.assert_called_once_with(wallet=wallet, swap_id=7) + + def test_contract_error_returns_false(self): + voter, client, _ = _make_voter() + client.timeout_swap.side_effect = RuntimeError('network error') + assert voter.timeout_swap(7) is False + + +class TestExtendTimeout: + def test_success_returns_true(self): + voter, client, wallet = _make_voter() + assert voter.extend_timeout(99) is True + client.vote_extend_timeout.assert_called_once_with(wallet=wallet, swap_id=99) + + def test_exception_propagates(self): + voter, client, _ = _make_voter() + client.vote_extend_timeout.side_effect = Exception('AlreadyVoted') + with pytest.raises(Exception, match='AlreadyVoted'): + voter.extend_timeout(99) diff --git a/uv.lock b/uv.lock index 467fa50..4074432 100644 --- a/uv.lock +++ b/uv.lock @@ -182,7 +182,6 @@ dependencies = [ { name = "python-dotenv" }, { name = "requests" }, { name = "rich" }, - { name = "rich-click" }, { name = "substrate-interface" }, { name = "wandb" }, ] @@ -190,6 +189,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -209,7 +209,6 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.1" }, { name = "requests" }, { name = "rich" }, - { name = "rich-click" }, { name = "substrate-interface" }, { name = "wandb", specifier = "==0.21.3" }, ] @@ -217,6 +216,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.14.10" }, ] @@ -748,6 +748,124 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cytoolz" version = "1.1.0" @@ -2389,6 +2507,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -2512,21 +2644,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] -[[package]] -name = "rich-click" -version = "1.9.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "rich" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, -] - [[package]] name = "ripemd-hash" version = "1.0.1" From d36f2e0c19263399bf41ccb57853aa3214620f72 Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:34:45 +0900 Subject: [PATCH 2/3] Add coverage artifacts to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index ebe488d..0d60981 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ allways-venv/ __pycache__ .pytest_cache/ +.coverage +htmlcov/ *.egg-info From 11a38ccf0cde5c0a81f0c0cec70167aa9b547d9a Mon Sep 17 00:00:00 2001 From: plind-dm <59729252+plind-dm@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:23:50 +0900 Subject: [PATCH 3/3] Expand test coverage to 55% across core modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit tests for previously uncovered modules: - SwapPoller incremental scan, cursor management, mark_processed - SwapTracker initialize, poll, prune_window, query helpers, _resolved_block - apply_recycle: emission vs fee scaling, recycle_uid accumulation, edge cases - commitments: decode_commitment_field, get_commitment, read_miner_commitment, read_miner_commitments - create_chain_providers: registry forwarding, check/require_send flags, failure handling - utils.misc: ttl_cache, _ttl_hash_gen, ttl_get_block - utils.logging: setup_events_logger, log_on_change dedup behaviour Extend existing suites for previously missed branches in fulfillment (non-TAO dest commitment lookup, save exception path) and pending_confirms (remove-nonexistent, closed-connection guard). Coverage: 43% → 55% (389 tests, +109 tests) --- tests/chain_providers/test_init.py | 85 +++++++ tests/core/test_commitments.py | 196 ++++++++++++++- tests/core/test_logging.py | 76 ++++++ tests/core/test_misc.py | 75 ++++++ tests/miner/test_fulfillment.py | 43 ++++ tests/miner/test_swap_poller.py | 169 +++++++++++++ tests/validator/test_pending_confirms.py | 13 + tests/validator/test_recycle.py | 121 +++++++++ tests/validator/test_swap_tracker.py | 307 +++++++++++++++++++++++ 9 files changed, 1083 insertions(+), 2 deletions(-) create mode 100644 tests/chain_providers/test_init.py create mode 100644 tests/core/test_logging.py create mode 100644 tests/core/test_misc.py create mode 100644 tests/miner/test_swap_poller.py create mode 100644 tests/validator/test_recycle.py create mode 100644 tests/validator/test_swap_tracker.py diff --git a/tests/chain_providers/test_init.py b/tests/chain_providers/test_init.py new file mode 100644 index 0000000..d17ace3 --- /dev/null +++ b/tests/chain_providers/test_init.py @@ -0,0 +1,85 @@ +"""Tests for allways.chain_providers — create_chain_providers factory.""" + +from unittest.mock import MagicMock, patch + +import pytest + +import allways.chain_providers as cp_module +from allways.chain_providers import create_chain_providers + + +def _mock_registry(*entries): + """Return a patched PROVIDER_REGISTRY with mock provider classes.""" + return patch.object(cp_module, 'PROVIDER_REGISTRY', entries) + + +class TestCreateChainProviders: + def test_returns_providers_for_all_entries(self): + MockCls = MagicMock(return_value=MagicMock()) + with _mock_registry(('btc', MockCls, ()), ('tao', MockCls, ())): + providers = create_chain_providers() + assert set(providers.keys()) == {'btc', 'tao'} + + def test_kwargs_forwarded_to_provider(self): + MockCls = MagicMock(return_value=MagicMock()) + subtensor = MagicMock() + with _mock_registry(('tao', MockCls, ('subtensor',))): + create_chain_providers(subtensor=subtensor) + MockCls.assert_called_once_with(subtensor=subtensor) + + def test_missing_kwarg_not_forwarded(self): + """If the registry lists 'subtensor' but it isn't passed, it's simply omitted.""" + MockCls = MagicMock(return_value=MagicMock()) + with _mock_registry(('tao', MockCls, ('subtensor',))): + create_chain_providers() # no subtensor kwarg + MockCls.assert_called_once_with() # called with no args + + def test_check_true_calls_check_connection(self): + inst = MagicMock() + MockCls = MagicMock(return_value=inst) + with _mock_registry(('btc', MockCls, ())): + create_chain_providers(check=True, require_send=False) + inst.check_connection.assert_called_once_with(require_send=False) + + def test_check_true_require_send_default_true(self): + inst = MagicMock() + MockCls = MagicMock(return_value=inst) + with _mock_registry(('btc', MockCls, ())): + create_chain_providers(check=True) + inst.check_connection.assert_called_once_with(require_send=True) + + def test_check_true_provider_init_failure_raises(self): + MockCls = MagicMock(side_effect=RuntimeError('no config')) + MockCls.__name__ = 'MockProvider' + with _mock_registry(('btc', MockCls, ())): + with pytest.raises(RuntimeError, match='failed startup check'): + create_chain_providers(check=True) + + def test_check_true_check_connection_failure_raises(self): + inst = MagicMock() + inst.check_connection.side_effect = RuntimeError('node down') + MockCls = MagicMock(return_value=inst) + MockCls.__name__ = 'MockProvider' + with _mock_registry(('btc', MockCls, ())): + with pytest.raises(RuntimeError, match='failed startup check'): + create_chain_providers(check=True) + + def test_check_false_failure_skips_provider(self): + MockFail = MagicMock(side_effect=RuntimeError('offline')) + MockFail.__name__ = 'MockFail' + MockOk = MagicMock(return_value=MagicMock()) + with _mock_registry(('btc', MockFail, ()), ('tao', MockOk, ())): + providers = create_chain_providers(check=False) + assert 'btc' not in providers + assert 'tao' in providers + + def test_check_false_no_check_connection_called(self): + inst = MagicMock() + MockCls = MagicMock(return_value=inst) + with _mock_registry(('btc', MockCls, ())): + create_chain_providers(check=False) + inst.check_connection.assert_not_called() + + def test_empty_registry_returns_empty(self): + with _mock_registry(): + assert create_chain_providers() == {} diff --git a/tests/core/test_commitments.py b/tests/core/test_commitments.py index e53be51..149489b 100644 --- a/tests/core/test_commitments.py +++ b/tests/core/test_commitments.py @@ -1,6 +1,14 @@ """Tests for allways.commitments — commitment string parsing.""" -from allways.commitments import parse_commitment_data +from unittest.mock import MagicMock, patch + +from allways.commitments import ( + decode_commitment_field, + get_commitment, + parse_commitment_data, + read_miner_commitment, + read_miner_commitments, +) class TestParseCommitmentData: @@ -160,4 +168,188 @@ def test_default_uid_and_hotkey(self): assert pair.hotkey == '' def test_same_chain(self): - assert parse_commitment_data('v1:btc:addr:btc:addr:340:350') is None + assert parse_commitment_data('v3:btc:addr:btc:addr:340:350') is None + + +# ─── decode_commitment_field ────────────────────────────────────────────────── + + +class TestDecodeCommitmentField: + def _hex_metadata(self, text: str): + """Build a metadata-like object with a hex-encoded string field.""" + hex_val = '0x' + text.encode().hex() + meta = MagicMock() + meta.value = {'info': {'fields': [{'Data': hex_val}]}} + return meta + + def _tuple_metadata(self, text: str): + """Build a metadata-like object with a byte-tuple field (bittensor SDK format).""" + byte_tuple = tuple(text.encode()) + meta = MagicMock() + meta.value = {'info': {'fields': [(({'Data': (byte_tuple,)},),)]}} + return meta + + def test_hex_string_decodes(self): + meta = self._hex_metadata('v3:btc:bc1q:tao:5C:340:350') + assert decode_commitment_field(meta) == 'v3:btc:bc1q:tao:5C:340:350' + + def test_byte_tuple_decodes(self): + text = 'v3:btc:bc1q:tao:5C:340:350' + byte_tuple = tuple(text.encode()) + meta = MagicMock() + meta.value = {'info': {'fields': [({'Data': (byte_tuple,)},)]}} + assert decode_commitment_field(meta) == text + + def test_none_value_returns_none(self): + meta = MagicMock() + meta.value = None + assert decode_commitment_field(meta) is None + + def test_missing_info_returns_none(self): + assert decode_commitment_field({}) is None + + def test_raw_dict_without_value_attr(self): + """metadata without .value attr — falls back to treating it as the val dict.""" + # Simulate no .value attribute + meta = {'info': {'fields': [{'Data': '0x' + 'hello'.encode().hex()}]}} + assert decode_commitment_field(meta) == 'hello' + + def test_field_with_value_attr(self): + """field has .value attr instead of being a plain dict.""" + field = MagicMock() + field.value = {'Data': '0x' + 'world'.encode().hex()} + meta = MagicMock() + meta.value = {'info': {'fields': [field]}} + assert decode_commitment_field(meta) == 'world' + + def test_malformed_metadata_returns_none(self): + """TypeError/KeyError/StopIteration paths return None.""" + assert decode_commitment_field({'info': {'fields': []}}) is None + assert decode_commitment_field({'info': {}}) is None + assert decode_commitment_field(42) is None + + +# ─── get_commitment ─────────────────────────────────────────────────────────── + + +class TestGetCommitment: + def _make_subtensor(self, query_result=None): + subtensor = MagicMock() + subtensor.substrate.query.return_value = query_result + subtensor.determine_block_hash.return_value = None + return subtensor + + def test_none_query_result_returns_none(self): + subtensor = self._make_subtensor(query_result=None) + assert get_commitment(subtensor, netuid=7, hotkey='hk') is None + + def test_valid_result_decoded(self): + hex_val = '0x' + 'v3:btc:bc1q:tao:5C:340:350'.encode().hex() + meta = MagicMock() + meta.value = {'info': {'fields': [{'Data': hex_val}]}} + subtensor = self._make_subtensor(query_result=meta) + result = get_commitment(subtensor, netuid=7, hotkey='hk') + assert result == 'v3:btc:bc1q:tao:5C:340:350' + + def test_block_passed_to_determine_hash(self): + subtensor = self._make_subtensor(query_result=None) + get_commitment(subtensor, netuid=7, hotkey='hk', block=42) + subtensor.determine_block_hash.assert_called_once_with(42) + + +# ─── read_miner_commitment ──────────────────────────────────────────────────── + + +class TestReadMinerCommitment: + def _make_subtensor_with_metagraph(self, hotkeys): + subtensor = MagicMock() + metagraph = MagicMock() + metagraph.n.item.return_value = len(hotkeys) + metagraph.hotkeys = hotkeys + subtensor.metagraph.return_value = metagraph + subtensor.substrate.query.return_value = None + subtensor.determine_block_hash.return_value = None + return subtensor, metagraph + + def test_hotkey_not_in_metagraph_returns_none(self): + subtensor, metagraph = self._make_subtensor_with_metagraph(['hk_other']) + assert read_miner_commitment(subtensor, netuid=7, hotkey='hk_missing', metagraph=metagraph) is None + + def test_no_commitment_returns_none(self): + subtensor, metagraph = self._make_subtensor_with_metagraph(['hk1']) + subtensor.substrate.query.return_value = None + assert read_miner_commitment(subtensor, netuid=7, hotkey='hk1', metagraph=metagraph) is None + + def test_valid_commitment_parsed(self): + hex_val = '0x' + 'v3:btc:bc1q:tao:5C:340:350'.encode().hex() + meta = MagicMock() + meta.value = {'info': {'fields': [{'Data': hex_val}]}} + subtensor, metagraph = self._make_subtensor_with_metagraph(['hk1']) + subtensor.substrate.query.return_value = meta + result = read_miner_commitment(subtensor, netuid=7, hotkey='hk1', metagraph=metagraph) + assert result is not None + assert result.source_chain == 'btc' + assert result.uid == 0 + + def test_fetches_metagraph_when_not_provided(self): + subtensor = MagicMock() + metagraph = MagicMock() + metagraph.n.item.return_value = 1 + metagraph.hotkeys = ['hk1'] + subtensor.metagraph.return_value = metagraph + subtensor.substrate.query.return_value = None + subtensor.determine_block_hash.return_value = None + read_miner_commitment(subtensor, netuid=7, hotkey='hk_missing') + subtensor.metagraph.assert_called_once_with(7) + + +# ─── read_miner_commitments ─────────────────────────────────────────────────── + + +class TestReadMinerCommitments: + def _make_subtensor(self, hotkeys, query_side_effect=None): + subtensor = MagicMock() + metagraph = MagicMock() + metagraph.n.item.return_value = len(hotkeys) + metagraph.hotkeys = hotkeys + subtensor.metagraph.return_value = metagraph + subtensor.determine_block_hash.return_value = None + if query_side_effect is not None: + subtensor.substrate.query.side_effect = query_side_effect + else: + subtensor.substrate.query.return_value = None + return subtensor + + def test_empty_metagraph_returns_empty(self): + subtensor = self._make_subtensor([]) + assert read_miner_commitments(subtensor, netuid=7) == [] + + def test_no_commitments_returns_empty(self): + subtensor = self._make_subtensor(['hk1', 'hk2']) + assert read_miner_commitments(subtensor, netuid=7) == [] + + def test_valid_commitments_returned(self): + hex_val = '0x' + 'v3:btc:bc1q:tao:5C:340:350'.encode().hex() + meta = MagicMock() + meta.value = {'info': {'fields': [{'Data': hex_val}]}} + subtensor = self._make_subtensor(['hk1'], query_side_effect=[meta]) + pairs = read_miner_commitments(subtensor, netuid=7) + assert len(pairs) == 1 + assert pairs[0].hotkey == 'hk1' + + def test_connection_error_returns_empty(self): + subtensor = self._make_subtensor(['hk1']) + subtensor.metagraph.side_effect = ConnectionError('offline') + assert read_miner_commitments(subtensor, netuid=7) == [] + + def test_generic_exception_returns_empty(self): + subtensor = self._make_subtensor(['hk1']) + subtensor.metagraph.side_effect = RuntimeError('unexpected') + assert read_miner_commitments(subtensor, netuid=7) == [] + + def test_invalid_commitment_string_skipped(self): + hex_val = '0x' + 'garbage'.encode().hex() + meta = MagicMock() + meta.value = {'info': {'fields': [{'Data': hex_val}]}} + subtensor = self._make_subtensor(['hk1'], query_side_effect=[meta]) + assert read_miner_commitments(subtensor, netuid=7) == [] diff --git a/tests/core/test_logging.py b/tests/core/test_logging.py new file mode 100644 index 0000000..e5b0cde --- /dev/null +++ b/tests/core/test_logging.py @@ -0,0 +1,76 @@ +"""Tests for allways.utils.logging.""" + +import logging +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from allways.utils.logging import log_on_change, setup_events_logger + + +class TestSetupEventsLogger: + def test_creates_logger_and_file(self, tmp_path): + logger = setup_events_logger(str(tmp_path), events_retention_size=1024 * 1024) + assert logger is not None + log_file = tmp_path / 'events.log' + assert log_file.exists() + + def test_logger_has_correct_level(self, tmp_path): + logger = setup_events_logger(str(tmp_path), events_retention_size=1024 * 1024) + assert logger.level == 38 + + def test_event_method_added_to_logger(self, tmp_path): + setup_events_logger(str(tmp_path), events_retention_size=1024 * 1024) + assert hasattr(logging.Logger, 'event') + + def test_event_method_logs_message(self, tmp_path): + logger = setup_events_logger(str(tmp_path), events_retention_size=1024 * 1024) + # Should not raise + logger.event('test event message') + log_file = tmp_path / 'events.log' + content = log_file.read_text() + assert 'test event message' in content + + +class TestLogOnChange: + def setup_method(self): + # Clear module-level _last_seen between tests + import allways.utils.logging as log_mod + log_mod._last_seen.clear() + + def test_logs_on_first_call(self): + with patch('bittensor.logging.info') as mock_log: + log_on_change('key1', 'value1', 'msg1') + mock_log.assert_called_once_with('msg1') + + def test_does_not_log_on_same_value(self): + with patch('bittensor.logging.info') as mock_log: + log_on_change('key2', 42, 'msg') + log_on_change('key2', 42, 'msg') + assert mock_log.call_count == 1 + + def test_logs_again_on_value_change(self): + with patch('bittensor.logging.info') as mock_log: + log_on_change('key3', 'a', 'first') + log_on_change('key3', 'b', 'second') + assert mock_log.call_count == 2 + + def test_different_keys_are_independent(self): + with patch('bittensor.logging.info') as mock_log: + log_on_change('k1', 1, 'msg1') + log_on_change('k2', 1, 'msg2') + assert mock_log.call_count == 2 + + def test_none_value_not_logged_when_absent_key(self): + # _last_seen.get(key) returns None when absent; None != None is False → no log + with patch('bittensor.logging.info') as mock_log: + log_on_change('key4', None, 'none_msg') + assert mock_log.call_count == 0 + + def test_non_none_then_none_logs_change(self): + with patch('bittensor.logging.info') as mock_log: + log_on_change('key5', 1, 'msg1') + log_on_change('key5', None, 'msg_none') + assert mock_log.call_count == 2 diff --git a/tests/core/test_misc.py b/tests/core/test_misc.py new file mode 100644 index 0000000..5578fe3 --- /dev/null +++ b/tests/core/test_misc.py @@ -0,0 +1,75 @@ +"""Tests for allways.utils.misc — ttl_cache and ttl_get_block.""" + +import time +from unittest.mock import MagicMock + +from allways.utils.misc import _ttl_hash_gen, ttl_cache, ttl_get_block + + +class TestTtlHashGen: + def test_yields_zero_initially(self): + gen = _ttl_hash_gen(10) + assert next(gen) == 0 + + def test_yields_same_hash_within_ttl(self): + gen = _ttl_hash_gen(100) + h1 = next(gen) + h2 = next(gen) + assert h1 == h2 + + def test_is_generator(self): + import types + gen = _ttl_hash_gen(10) + assert isinstance(gen, types.GeneratorType) + + +class TestTtlCache: + def test_caches_result(self): + call_count = 0 + + @ttl_cache(maxsize=1, ttl=60) + def fn(x): + nonlocal call_count + call_count += 1 + return x * 2 + + assert fn(3) == 6 + assert fn(3) == 6 + assert call_count == 1 + + def test_negative_ttl_uses_large_default(self): + """ttl <= 0 should still work (defaults to 65536).""" + @ttl_cache(maxsize=1, ttl=-1) + def fn(x): + return x + 1 + + assert fn(5) == 6 + + def test_preserves_function_name(self): + @ttl_cache(maxsize=1, ttl=60) + def my_function(x): + return x + + assert my_function.__name__ == 'my_function' + + def test_different_args_not_cached_together(self): + call_count = 0 + + @ttl_cache(maxsize=128, ttl=60) + def fn(x): + nonlocal call_count + call_count += 1 + return x + + fn(1) + fn(2) + assert call_count == 2 + + +class TestTtlGetBlock: + def test_calls_subtensor_get_current_block(self): + obj = MagicMock() + obj.subtensor.get_current_block.return_value = 42 + # ttl_get_block is a module-level cached function; call with obj as self + result = ttl_get_block(obj) + assert result == 42 diff --git a/tests/miner/test_fulfillment.py b/tests/miner/test_fulfillment.py index 631d4a0..c23e6c7 100644 --- a/tests/miner/test_fulfillment.py +++ b/tests/miner/test_fulfillment.py @@ -66,6 +66,13 @@ def test_save_and_reload(self, tmp_path): data = json.loads(path.read_text()) assert data == {'3': ['txabc', 77]} + def test_save_exception_logged(self, tmp_path): + path = tmp_path / 'sent.json' + fulfiller, *_ = _make_fulfiller(sent_cache_path=path) + fulfiller._sent[1] = ('txhash', 42) + with patch('allways.miner.fulfillment.Path.rename', side_effect=OSError('disk full')): + fulfiller._save_sent_cache() # should not raise + # ─── cleanup_stale_sends ───────────────────────────────────────────────────── @@ -200,6 +207,42 @@ def test_send_fails_returns_none(self): swap = make_swap(dest_chain='tao') assert fulfiller.send_dest_funds(swap, 3_465_000_000) is None + def test_non_tao_dest_uses_commitment_address(self): + btc_provider = MagicMock() + btc_provider.send_amount.return_value = ('btc_tx', 300) + fulfiller, *_ = _make_fulfiller(providers={'btc': btc_provider}) + swap = make_swap(source_chain='tao', dest_chain='btc') + + commitment = MagicMock() + commitment.source_address = 'bc1qminer_from' + commitment.source_chain = 'btc' + with patch('allways.commitments.read_miner_commitment', return_value=commitment): + result = fulfiller.send_dest_funds(swap, 500_000) + assert result == ('btc_tx', 300) + btc_provider.send_amount.assert_called_once_with('5Fdest', 500_000, key=None, from_address='bc1qminer_from') + + def test_non_tao_dest_no_commitment_proceeds(self): + btc_provider = MagicMock() + btc_provider.send_amount.return_value = ('btc_tx', 301) + fulfiller, *_ = _make_fulfiller(providers={'btc': btc_provider}) + swap = make_swap(source_chain='tao', dest_chain='btc') + + with patch('allways.commitments.read_miner_commitment', return_value=None): + result = fulfiller.send_dest_funds(swap, 500_000) + assert result == ('btc_tx', 301) + btc_provider.send_amount.assert_called_once_with('5Fdest', 500_000, key=None, from_address=None) + + def test_non_tao_dest_commitment_exception_proceeds(self): + btc_provider = MagicMock() + btc_provider.send_amount.return_value = ('btc_tx', 302) + fulfiller, *_ = _make_fulfiller(providers={'btc': btc_provider}) + swap = make_swap(source_chain='tao', dest_chain='btc') + + with patch('allways.commitments.read_miner_commitment', side_effect=RuntimeError('err')): + result = fulfiller.send_dest_funds(swap, 500_000) + assert result == ('btc_tx', 302) + btc_provider.send_amount.assert_called_once_with('5Fdest', 500_000, key=None, from_address=None) + # ─── process_swap ───────────────────────────────────────────────────────────── diff --git a/tests/miner/test_swap_poller.py b/tests/miner/test_swap_poller.py new file mode 100644 index 0000000..fdafe76 --- /dev/null +++ b/tests/miner/test_swap_poller.py @@ -0,0 +1,169 @@ +"""Tests for allways.miner.swap_poller — SwapPoller.""" + +from unittest.mock import MagicMock + +from allways.classes import SwapStatus +from allways.miner.swap_poller import SwapPoller +from tests.helpers import make_swap + + +def _make_poller(miner_hotkey='5Fminer'): + client = MagicMock() + poller = SwapPoller(contract_client=client, miner_hotkey=miner_hotkey) + return poller, client + + +# ─── poll (outer wrapper) ───────────────────────────────────────────────────── + + +class TestPollWrapper: + def test_exception_returns_empty_and_sets_flag(self): + poller, client = _make_poller() + client.get_next_swap_id.side_effect = ConnectionError('offline') + new_pending, fulfilled = poller.poll() + assert new_pending == [] + assert fulfilled == [] + assert poller.last_poll_ok is False + + def test_success_sets_last_poll_ok(self): + poller, client = _make_poller() + client.get_next_swap_id.return_value = 1 # no swaps + poller.poll() + assert poller.last_poll_ok is True + + +# ─── _poll_inner — discovery ────────────────────────────────────────────────── + + +class TestPollInnerDiscovery: + def test_no_swaps_returns_empty(self): + poller, client = _make_poller() + client.get_next_swap_id.return_value = 1 # range(1,1) is empty + pending, fulfilled = poller.poll() + assert pending == [] + assert fulfilled == [] + + def test_new_active_swap_appears_in_pending(self): + poller, client = _make_poller() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + pending, fulfilled = poller.poll() + assert len(pending) == 1 + assert pending[0].id == 1 + + def test_fulfilled_swap_appears_in_fulfilled(self): + poller, client = _make_poller() + swap = make_swap(id=1, status=SwapStatus.FULFILLED, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + pending, fulfilled = poller.poll() + assert len(pending) == 0 + assert len(fulfilled) == 1 + + def test_swap_for_different_miner_skipped(self): + poller, client = _make_poller(miner_hotkey='5Fmine') + swap = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fother') + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + pending, fulfilled = poller.poll() + assert pending == [] + + def test_cursor_advances(self): + poller, client = _make_poller() + client.get_next_swap_id.return_value = 5 + client.get_swap.return_value = None + poller.poll() + assert poller.last_scanned_id == 4 + + def test_cursor_does_not_advance_when_next_id_is_1(self): + poller, client = _make_poller() + client.get_next_swap_id.return_value = 1 + poller.poll() + assert poller.last_scanned_id == 0 + + +# ─── _poll_inner — refresh ──────────────────────────────────────────────────── + + +class TestPollInnerRefresh: + def test_active_swap_removed_when_timed_out(self): + poller, client = _make_poller() + swap_active = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + swap_timed_out = make_swap(id=1, status=SwapStatus.TIMED_OUT, miner_hotkey='5Fminer') + # First poll discovers swap + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap_active + poller.poll() + assert 1 in poller.active + # Second poll: no new swaps, but refresh shows timed-out + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap_timed_out + pending, _ = poller.poll() + assert 1 not in poller.active + assert pending == [] + + def test_active_swap_removed_when_get_swap_returns_none(self): + poller, client = _make_poller() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + poller.poll() + client.get_swap.return_value = None + client.get_next_swap_id.return_value = 2 + poller.poll() + assert 1 not in poller.active + + def test_existing_active_swap_updated(self): + poller, client = _make_poller() + swap_v1 = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + swap_v2 = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer', source_amount=9_999) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap_v1 + poller.poll() + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap_v2 + poller.poll() + assert poller.active[1].source_amount == 9_999 + + def test_freshly_discovered_skipped_in_refresh(self): + """Swap discovered this poll should not be double-refreshed.""" + poller, client = _make_poller() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + # get_swap called exactly once (discovery), not a second time (refresh) + client.get_swap.return_value = swap + poller.poll() + # Called once for discovery (id=1), not for refresh + assert client.get_swap.call_count == 1 + + +# ─── mark_processed ─────────────────────────────────────────────────────────── + + +class TestMarkProcessed: + def test_processed_swap_not_in_pending_again(self): + poller, client = _make_poller() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + pending, _ = poller.poll() + assert len(pending) == 1 + poller.mark_processed(1) + # Second poll — swap still active but processed + client.get_next_swap_id.return_value = 2 + pending2, _ = poller.poll() + assert pending2 == [] + + def test_processed_cleared_when_swap_resolved(self): + poller, client = _make_poller() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + poller.poll() + poller.mark_processed(1) + # Swap resolves + client.get_swap.return_value = make_swap(id=1, status=SwapStatus.TIMED_OUT, miner_hotkey='5Fminer') + client.get_next_swap_id.return_value = 2 + poller.poll() + assert 1 not in poller._processed diff --git a/tests/validator/test_pending_confirms.py b/tests/validator/test_pending_confirms.py index 90d5c8f..895342c 100644 --- a/tests/validator/test_pending_confirms.py +++ b/tests/validator/test_pending_confirms.py @@ -90,6 +90,19 @@ def test_has_reflects_enqueue_and_remove(self, tmp_path: Path): assert removed.miner_hotkey == 'miner-1' assert not queue.has('miner-1') + def test_remove_nonexistent_returns_none(self, tmp_path: Path): + db_path = tmp_path / 'pending_confirms.db' + queue = PendingConfirmQueue(db_path=db_path) + assert queue.remove('no-such-miner') is None + + def test_require_connection_raises_after_close(self, tmp_path: Path): + import pytest + db_path = tmp_path / 'pending_confirms.db' + queue = PendingConfirmQueue(db_path=db_path) + queue.close() + with pytest.raises(RuntimeError, match='closed'): + queue.get_all() + def test_reads_purge_expired_entries(self, tmp_path: Path): db_path = tmp_path / 'pending_confirms.db' queue = PendingConfirmQueue( diff --git a/tests/validator/test_recycle.py b/tests/validator/test_recycle.py new file mode 100644 index 0000000..f27cb8e --- /dev/null +++ b/tests/validator/test_recycle.py @@ -0,0 +1,121 @@ +"""Tests for allways.validator.recycle — apply_recycle.""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from allways.constants import RECYCLE_UID, TAO_TO_RAO +from allways.validator.recycle import apply_recycle +from allways.validator.swap_tracker import SwapTracker + + +def _make_validator(n_uids=10, alpha_price_tao=1.0): + validator = MagicMock() + validator.metagraph.n.item.return_value = n_uids + validator.subtensor.get_subnet_price.return_value = MagicMock(tao=alpha_price_tao) + validator.config.netuid = 7 + validator.fee_divisor = 100 + return validator + + +def _make_tracker(completed_tao_amounts=None): + client = MagicMock() + tracker = SwapTracker(client=client, fulfillment_timeout_blocks=100, window_blocks=360) + if completed_tao_amounts: + from allways.classes import SwapStatus + from tests.helpers import make_swap + for i, amount in enumerate(completed_tao_amounts): + swap = make_swap(id=i + 1, status=SwapStatus.COMPLETED, tao_amount=amount) + tracker.window.append(swap) + return tracker + + +class TestApplyRecycle: + def test_no_miners_returns_recycle_uid_only(self): + validator = _make_validator(n_uids=10) + tracker = _make_tracker() + rewards, uids = apply_recycle(validator, np.array([]), set(), tracker) + assert set(uids) == {RECYCLE_UID if RECYCLE_UID < 10 else 0} + assert len(rewards) == 1 + + def test_recycle_uid_out_of_bounds_falls_back_to_zero(self): + validator = _make_validator(n_uids=1) # only uid=0 exists, RECYCLE_UID likely >= 1 + tracker = _make_tracker() + rewards, uids = apply_recycle(validator, np.array([]), set(), tracker) + assert 0 in uids + + def test_subnet_price_error_returns_original(self): + validator = _make_validator() + validator.subtensor.get_subnet_price.side_effect = RuntimeError('unavailable') + tracker = _make_tracker() + original_rewards = np.array([0.5, 0.5], dtype=np.float32) + original_uids = {0, 1} + rewards, uids = apply_recycle(validator, original_rewards, original_uids, tracker) + np.testing.assert_array_equal(rewards, original_rewards) + assert uids == original_uids + + def test_fees_exceed_emission_no_recycling(self): + """If fees >= emission, recycle_fraction=0 → rewards unchanged, uids unchanged.""" + from allways.constants import DAILY_EMISSION_ALPHA, SCORING_WINDOW_BLOCKS + + validator = _make_validator(alpha_price_tao=0.001) # tiny emission + # Give large fees so fees_tao >> emission_tao + tracker = _make_tracker(completed_tao_amounts=[1000 * TAO_TO_RAO] * 5) + rewards = np.array([0.5, 0.5], dtype=np.float32) + uids = {0, 1} + new_rewards, new_uids = apply_recycle(validator, rewards, uids, tracker) + np.testing.assert_array_almost_equal(new_rewards, rewards) + assert new_uids == uids + + def test_zero_fees_full_recycling(self): + """Zero fees → full recycle fraction → recycle uid is added to result.""" + validator = _make_validator(n_uids=20, alpha_price_tao=10.0) # large emission + tracker = _make_tracker(completed_tao_amounts=[]) # no fees + # Use uids that don't include recycle_uid (falls back to 0 since RECYCLE_UID>=20) + rewards = np.array([0.5, 0.5], dtype=np.float32) + uids = {5, 6} # neither is 0 (fallback recycle_uid) + new_rewards, new_uids = apply_recycle(validator, rewards, uids, tracker) + recycle_uid = RECYCLE_UID if RECYCLE_UID < 20 else 0 + assert recycle_uid in new_uids + + def test_partial_recycling_scales_rewards(self): + """Fees < emission → recycle_fraction > 0 → result has more uids than input.""" + from allways.constants import DAILY_EMISSION_ALPHA, SCORING_WINDOW_BLOCKS + + validator = _make_validator(n_uids=20, alpha_price_tao=1.0) + # No fees → full recycle → recycle_uid added to output + tracker = _make_tracker(completed_tao_amounts=[]) + # Use uid 5 to avoid collision with recycle fallback uid (0) + rewards = np.array([1.0], dtype=np.float32) + uids = {5} + new_rewards, new_uids = apply_recycle(validator, rewards, uids, tracker) + # recycle_uid (0) gets added, so len > 1 + assert len(new_uids) > len(uids) + # miner reward scaled down (recycle_fraction = 1.0 since no fees) + miner_idx = sorted(new_uids).index(5) + assert new_rewards[miner_idx] == pytest.approx(0.0, abs=0.01) + + def test_recycle_uid_already_in_miner_set_accumulates(self): + """If recycle_uid is also a scored miner, recycle amount accumulates onto its reward.""" + # Force recycle_uid = 0 by using n_uids=1 (RECYCLE_UID >= 1 so fallback to 0) + validator = _make_validator(n_uids=1, alpha_price_tao=10.0) + tracker = _make_tracker(completed_tao_amounts=[]) # no fees → recycle_fraction=1.0 + # uid 0 is both a miner and the recycle_uid + uids = {0} + rewards = np.array([0.3], dtype=np.float32) + new_rewards, new_uids = apply_recycle(validator, rewards, uids, tracker) + assert 0 in new_uids + # miner reward = 0.3 * 0 + 1.0 = 1.0 + idx = sorted(new_uids).index(0) + assert new_rewards[idx] == pytest.approx(1.0, abs=0.01) + + def test_zero_emission_no_recycling(self): + """alpha_price_tao=0 → emission_tao=0 → recycle_fraction=0 → no change.""" + validator = _make_validator(n_uids=10, alpha_price_tao=0.0) + tracker = _make_tracker() + rewards = np.array([0.5, 0.5], dtype=np.float32) + uids = {0, 1} + new_rewards, new_uids = apply_recycle(validator, rewards, uids, tracker) + np.testing.assert_array_equal(new_rewards, rewards) + assert new_uids == uids diff --git a/tests/validator/test_swap_tracker.py b/tests/validator/test_swap_tracker.py new file mode 100644 index 0000000..4ca92e3 --- /dev/null +++ b/tests/validator/test_swap_tracker.py @@ -0,0 +1,307 @@ +"""Tests for allways.validator.swap_tracker — SwapTracker.""" + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from allways.classes import SwapStatus +from allways.validator.swap_tracker import SwapTracker, _resolved_block +from tests.helpers import make_swap + + +def _make_tracker(fulfillment_timeout_blocks=100, window_blocks=360): + client = MagicMock() + return SwapTracker( + client=client, + fulfillment_timeout_blocks=fulfillment_timeout_blocks, + window_blocks=window_blocks, + ), client + + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# ─── initialize ─────────────────────────────────────────────────────────────── + + +class TestInitialize: + def test_no_swaps(self): + tracker, client = _make_tracker() + client.get_next_swap_id.return_value = 1 + tracker.initialize(current_block=1000) + assert tracker.last_scanned_id == 0 + assert tracker.active == {} + + def test_active_swap_added(self): + tracker, client = _make_tracker(window_blocks=360, fulfillment_timeout_blocks=100) + swap = make_swap(id=1, status=SwapStatus.ACTIVE) + swap = swap.__class__(**{**swap.__dict__, 'initiated_block': 900}) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + tracker.initialize(current_block=1000) + assert 1 in tracker.active + assert tracker.last_scanned_id == 1 + + def test_old_swap_stops_scan(self): + tracker, client = _make_tracker(window_blocks=360, fulfillment_timeout_blocks=100) + # cutoff = 1000 - 360 - 100 = 540; swap initiated at 300 is too old + old_swap = make_swap(id=1, status=SwapStatus.ACTIVE) + old_swap = old_swap.__class__(**{**old_swap.__dict__, 'initiated_block': 300}) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = old_swap + tracker.initialize(current_block=1000) + assert tracker.active == {} + + def test_none_swap_skipped(self): + tracker, client = _make_tracker() + client.get_next_swap_id.return_value = 3 + client.get_swap.return_value = None + tracker.initialize(current_block=1000) + assert tracker.active == {} + assert tracker.last_scanned_id == 2 + + def test_non_active_swap_not_added(self): + tracker, client = _make_tracker(window_blocks=360, fulfillment_timeout_blocks=100) + swap = make_swap(id=1, status=SwapStatus.COMPLETED) + swap = swap.__class__(**{**swap.__dict__, 'initiated_block': 900}) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + tracker.initialize(current_block=1000) + assert tracker.active == {} + + +# ─── mark_voted / is_voted ──────────────────────────────────────────────────── + + +class TestVoting: + def test_not_voted_initially(self): + tracker, _ = _make_tracker() + assert tracker.is_voted(42) is False + + def test_mark_and_check(self): + tracker, _ = _make_tracker() + tracker.mark_voted(42) + assert tracker.is_voted(42) is True + + def test_different_id_not_voted(self): + tracker, _ = _make_tracker() + tracker.mark_voted(1) + assert tracker.is_voted(2) is False + + +# ─── poll ───────────────────────────────────────────────────────────────────── + + +class TestPoll: + def test_connection_error_does_not_raise(self): + tracker, client = _make_tracker() + client.get_next_swap_id.side_effect = ConnectionError('offline') + _run(tracker.poll()) # should not raise + + def test_generic_exception_propagates(self): + tracker, client = _make_tracker() + client.get_next_swap_id.side_effect = RuntimeError('unexpected') + with pytest.raises(RuntimeError): + _run(tracker.poll()) + + def test_new_active_swap_discovered(self): + tracker, client = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + _run(tracker.poll()) + assert 1 in tracker.active + + def test_cursor_advances(self): + tracker, client = _make_tracker() + client.get_next_swap_id.return_value = 5 + client.get_swap.return_value = None + _run(tracker.poll()) + assert tracker.last_scanned_id == 4 + + def test_no_new_swaps_cursor_stays(self): + tracker, client = _make_tracker() + client.get_next_swap_id.return_value = 1 + _run(tracker.poll()) + assert tracker.last_scanned_id == 0 + + def test_resolved_swap_moved_to_window(self): + tracker, client = _make_tracker() + active_swap = make_swap(id=1, status=SwapStatus.ACTIVE) + completed_swap = make_swap(id=1, status=SwapStatus.COMPLETED) + # First poll discovers swap + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = active_swap + _run(tracker.poll()) + assert 1 in tracker.active + # Second poll: monitoring detects it resolved + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = completed_swap + _run(tracker.poll()) + assert 1 not in tracker.active + assert len(tracker.window) == 1 + + def test_none_resolved_swap_removed_from_active(self): + tracker, client = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + _run(tracker.poll()) + client.get_swap.return_value = None + client.get_next_swap_id.return_value = 2 + _run(tracker.poll()) + assert 1 not in tracker.active + + def test_voted_id_cleared_on_resolve(self): + tracker, client = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + _run(tracker.poll()) + tracker.mark_voted(1) + client.get_swap.return_value = make_swap(id=1, status=SwapStatus.COMPLETED) + client.get_next_swap_id.return_value = 2 + _run(tracker.poll()) + assert not tracker.is_voted(1) + + def test_fulfilled_swap_added_to_active(self): + tracker, client = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.FULFILLED) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + _run(tracker.poll()) + assert 1 in tracker.active + + def test_active_swap_updated_in_monitoring(self): + tracker, client = _make_tracker() + swap_v1 = make_swap(id=1, status=SwapStatus.ACTIVE, source_amount=1_000_000) + swap_v2 = make_swap(id=1, status=SwapStatus.ACTIVE, source_amount=2_000_000) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap_v1 + _run(tracker.poll()) + # Second poll: no new IDs, monitoring refreshes existing + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap_v2 + _run(tracker.poll()) + assert tracker.active[1].source_amount == 2_000_000 + + def test_freshly_discovered_skipped_in_monitoring(self): + tracker, client = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE) + client.get_next_swap_id.return_value = 2 + client.get_swap.return_value = swap + _run(tracker.poll()) + # On discovery poll, get_swap called once for id=1 + assert client.get_swap.call_count == 1 + + +# ─── prune_window ───────────────────────────────────────────────────────────── + + +class TestPruneWindow: + def test_prunes_old_swaps(self): + tracker, _ = _make_tracker(window_blocks=100) + old = make_swap(id=1, status=SwapStatus.COMPLETED) + old = old.__class__(**{**old.__dict__, 'completed_block': 500, 'timeout_block': 0}) + recent = make_swap(id=2, status=SwapStatus.COMPLETED) + recent = recent.__class__(**{**recent.__dict__, 'completed_block': 950, 'timeout_block': 0}) + tracker.window = [old, recent] + tracker.prune_window(current_block=1000) + assert len(tracker.window) == 1 + assert tracker.window[0].id == 2 + + def test_no_pruning_when_all_recent(self): + tracker, _ = _make_tracker(window_blocks=100) + swap = make_swap(id=1, status=SwapStatus.COMPLETED) + swap = swap.__class__(**{**swap.__dict__, 'completed_block': 990, 'timeout_block': 0}) + tracker.window = [swap] + tracker.prune_window(current_block=1000) + assert len(tracker.window) == 1 + + def test_empty_window_no_error(self): + tracker, _ = _make_tracker() + tracker.prune_window(current_block=1000) + assert tracker.window == [] + + +# ─── get_fulfilled / get_near_timeout_fulfilled / get_timed_out ─────────────── + + +class TestQueryMethods: + def test_get_fulfilled_returns_fulfilled_within_timeout(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.FULFILLED, timeout_block=1100) + tracker.active[1] = swap + result = tracker.get_fulfilled(current_block=1000) + assert len(result) == 1 + + def test_get_fulfilled_excludes_past_timeout(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.FULFILLED, timeout_block=900) + tracker.active[1] = swap + result = tracker.get_fulfilled(current_block=1000) + assert result == [] + + def test_get_fulfilled_timeout_zero_always_included(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.FULFILLED, timeout_block=0) + tracker.active[1] = swap + result = tracker.get_fulfilled(current_block=99999) + assert len(result) == 1 + + def test_get_near_timeout_fulfilled(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.FULFILLED, timeout_block=1010) + tracker.active[1] = swap + result = tracker.get_near_timeout_fulfilled(current_block=1000, threshold=20) + assert len(result) == 1 + + def test_get_near_timeout_not_near(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.FULFILLED, timeout_block=2000) + tracker.active[1] = swap + result = tracker.get_near_timeout_fulfilled(current_block=1000, threshold=20) + assert result == [] + + def test_get_timed_out(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, timeout_block=900) + tracker.active[1] = swap + result = tracker.get_timed_out(current_block=1000) + assert len(result) == 1 + + def test_get_timed_out_not_yet(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, timeout_block=1100) + tracker.active[1] = swap + result = tracker.get_timed_out(current_block=1000) + assert result == [] + + def test_get_timed_out_zero_timeout_excluded(self): + tracker, _ = _make_tracker() + swap = make_swap(id=1, status=SwapStatus.ACTIVE, timeout_block=0) + tracker.active[1] = swap + result = tracker.get_timed_out(current_block=99999) + assert result == [] + + +# ─── _resolved_block ────────────────────────────────────────────────────────── + + +class TestResolvedBlock: + def test_uses_completed_block(self): + swap = make_swap(id=1) + swap = swap.__class__(**{**swap.__dict__, 'completed_block': 500, 'timeout_block': 900}) + assert _resolved_block(swap) == 500 + + def test_falls_back_to_timeout_block(self): + swap = make_swap(id=1) + swap = swap.__class__(**{**swap.__dict__, 'completed_block': 0, 'timeout_block': 900}) + assert _resolved_block(swap) == 900 + + def test_falls_back_to_initiated_block(self): + swap = make_swap(id=1) + swap = swap.__class__(**{**swap.__dict__, 'completed_block': 0, 'timeout_block': 0, 'initiated_block': 100}) + assert _resolved_block(swap) == 100