From d03bc885f581194d5de56b70fa9cdfac6958934b Mon Sep 17 00:00:00 2001 From: John Richard Date: Sat, 4 Apr 2026 11:12:46 +0800 Subject: [PATCH 1/5] Refactor: plugin --- .github/workflows/CI.yml | 5 - .github/workflows/PR.yml | 14 +- .gitignore | 3 +- README.md | 2 + nonebot_plugin_value/api/api_balance.py | 16 +- nonebot_plugin_value/api/api_currency.py | 24 +- nonebot_plugin_value/api/api_transaction.py | 9 +- nonebot_plugin_value/hook/exception.py | 6 +- nonebot_plugin_value/hook/hooks_manager.py | 21 +- nonebot_plugin_value/repositories/account.py | 274 ++++++++---------- nonebot_plugin_value/repositories/currency.py | 166 +++++------ .../repositories/transaction.py | 96 +++--- nonebot_plugin_value/services/balance.py | 180 ++++++------ nonebot_plugin_value/services/currency.py | 71 +++-- nonebot_plugin_value/services/transaction.py | 13 +- pyproject.toml | 31 +- run_test.sh | 3 + tests/balance_transfer.py | 46 +++ tests/batch_run.py | 99 +++++++ tests/{value_tests => }/cache_test.py | 49 ++-- tests/conftest.py | 2 +- tests/create_currency.py | 69 +++++ tests/default_account.py | 47 +++ tests/depends.py | 60 ++++ tests/test_api_coverage.py | 155 ++++++++++ tests/test_dump_tools.py | 129 +++++++++ tests/test_hooks.py | 145 +++++++++ tests/transaction_histories.py | 47 +++ tests/value_tests/balance_transfer.py | 19 -- tests/value_tests/batch_run.py | 44 --- tests/value_tests/create_currency.py | 30 -- tests/value_tests/default_account.py | 27 -- tests/value_tests/depends.py | 35 --- tests/value_tests/transaction_histories.py | 16 - uv.lock | 138 ++++++++- 35 files changed, 1465 insertions(+), 626 deletions(-) create mode 100755 run_test.sh create mode 100644 tests/balance_transfer.py create mode 100644 tests/batch_run.py rename tests/{value_tests => }/cache_test.py (59%) create mode 100644 tests/create_currency.py create mode 100644 tests/default_account.py create mode 100644 tests/depends.py create mode 100644 tests/test_api_coverage.py create mode 100644 tests/test_dump_tools.py create mode 100644 tests/test_hooks.py create mode 100644 tests/transaction_histories.py delete mode 100644 tests/value_tests/balance_transfer.py delete mode 100644 tests/value_tests/batch_run.py delete mode 100644 tests/value_tests/create_currency.py delete mode 100644 tests/value_tests/default_account.py delete mode 100644 tests/value_tests/depends.py delete mode 100644 tests/value_tests/transaction_histories.py diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9ff0cdd..00bf432 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -41,11 +41,6 @@ jobs: uses: astral-sh/ruff-action@v3 with: args: check . --exit-non-zero-on-fix - - - name: Prepare database - run: uv run nb orm upgrade - - name: Test - run: uv run pytest ./tests/value_tests/* - name: Build package run: uv build # 生成构建产物到dist目录 diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml index cc1de55..8ea66e2 100644 --- a/.github/workflows/PR.yml +++ b/.github/workflows/PR.yml @@ -45,8 +45,18 @@ jobs: - name: Prepare database run: uv run nb orm upgrade - - name: Test - run: uv run pytest ./tests/value_tests/* + - name: Run Unit Tests with JUnit XML output + run: uv run pytest tests/* --cov=nonebot_plugin_value --cov-report=term-missing --cov-report=xml --junitxml=test-results.xml -v + + - name: Publish Test Report + uses: dorny/test-reporter@v1 + if: success() || failure() + with: + name: Python Unit Tests + path: test-results.xml + reporter: java-junit + fail-on-error: false + - name: Build package run: uv build # 生成构建产物到dist目录 diff --git a/.gitignore b/.gitignore index 855897f..57858fc 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,5 @@ cython_debug/ /data/ /config/ /logs/ -/.ruff_cache/ \ No newline at end of file +/.ruff_cache/ +test-results.xml \ No newline at end of file diff --git a/README.md b/README.md index 3a10514..20afd22 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@

+> *为 NoneBot2 设计的娱乐系统基础设施。* + ## 核心特性 `nonebot_plugin_value` 是一个基于 NoneBot2 的通用经济系统插件,提供以下核心功能: diff --git a/nonebot_plugin_value/api/api_balance.py b/nonebot_plugin_value/api/api_balance.py index 155b70a..3549c67 100644 --- a/nonebot_plugin_value/api/api_balance.py +++ b/nonebot_plugin_value/api/api_balance.py @@ -33,6 +33,7 @@ async def set_frozen_all(account_id: str, frozen: bool) -> None: data_id=get_uni_id(account_id, currency.id), ) await _set_frozen_all(account_id, frozen, session) + await session.commit() async def set_frozen(account_id: str, currency_id: str, frozen: bool) -> None: @@ -49,6 +50,7 @@ async def set_frozen(account_id: str, currency_id: str, frozen: bool) -> None: data_id=get_uni_id(account_id, currency_id), ) await _set_frozen(account_id, frozen, currency_id, session) + await session.commit() async def list_accounts( @@ -65,14 +67,8 @@ async def list_accounts( if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex async with get_session() as session: - result = [ - UserAccountData( - id=account.id, - uni_id=account.uni_id, - currency_id=account.currency_id, - balance=account.balance, - last_updated=account.last_updated, - ) + result: list[UserAccountData] = [ + UserAccountData.model_validate(account, from_attributes=True) for account in await _list_accounts(session, currency_id) ] if not no_cache_refresh: @@ -137,6 +133,7 @@ async def get_or_create_account( category=CacheCategoryEnum.ACCOUNT, data=data, ) + await session.commit() return data @@ -241,7 +238,8 @@ async def del_balance( """ if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex - data = await _d_balance(user_id, currency_id, amount, source) + async with get_session() as session: + data = await _d_balance(user_id, currency_id, amount, session, source) if not data.success: raise RuntimeError(data.message) await CacheManager().expire_cache( diff --git a/nonebot_plugin_value/api/api_currency.py b/nonebot_plugin_value/api/api_currency.py index dba6f75..88fcf33 100644 --- a/nonebot_plugin_value/api/api_currency.py +++ b/nonebot_plugin_value/api/api_currency.py @@ -26,9 +26,10 @@ async def update_currency(currency_data: CurrencyData) -> CurrencyData: async with get_session() as session: currency = await _update_currency(currency_data, session) data = CurrencyData.model_validate(currency, from_attributes=True) - await CacheManager().update_cache( + await session.commit() + await CacheManager().expire_cache( category=CacheCategoryEnum.CURRENCY, - data=data, + data_id=data.id, ) return data @@ -42,7 +43,9 @@ async def remove_currency(currency_id: str) -> None: await CacheManager().expire_cache( category=CacheCategoryEnum.CURRENCY, data_id=currency_id ) - await _remove_currency(currency_id) + async with get_session() as session: + await _remove_currency(currency_id) + await session.commit() async def list_currencies(no_cache_update: bool = False) -> list[CurrencyData]: @@ -57,6 +60,7 @@ async def list_currencies(no_cache_update: bool = False) -> list[CurrencyData]: CurrencyData.model_validate(currency, from_attributes=True) for currency in currencies ] + await session.commit() if not no_cache_update: for currency in result: await CacheManager().update_cache( @@ -84,7 +88,9 @@ async def get_currency(currency_id: str, no_cache: bool = False) -> CurrencyData currency = await _g_currency(currency_id, session) if currency is None: return None - return CurrencyData.model_validate(currency, from_attributes=True) + data = CurrencyData.model_validate(currency, from_attributes=True) + await session.commit() + return data async def get_currency_by_kwargs(**kwargs: object) -> CurrencyData | None: @@ -100,7 +106,9 @@ async def get_currency_by_kwargs(**kwargs: object) -> CurrencyData | None: currency = await __currency_by_kwargs(**kwargs, session=session) if currency is None: return None - return CurrencyData.model_validate(currency, from_attributes=True) + data = CurrencyData.model_validate(currency, from_attributes=True) + await session.commit() + return data async def get_default_currency() -> CurrencyData: @@ -111,7 +119,9 @@ async def get_default_currency() -> CurrencyData: """ async with get_session() as session: currency = await _default_currency(session) - return CurrencyData.model_validate(currency, from_attributes=True) + data = CurrencyData.model_validate(currency, from_attributes=True) + await session.commit() + return data async def create_currency(currency_data: CurrencyData) -> None: @@ -129,6 +139,7 @@ async def create_currency(currency_data: CurrencyData) -> None: data=currency_data, ) await _create_currency(currency_data, session) + await session.commit() async def get_or_create_currency(currency_data: CurrencyData) -> CurrencyData: @@ -147,4 +158,5 @@ async def get_or_create_currency(currency_data: CurrencyData) -> CurrencyData: category=CacheCategoryEnum.CURRENCY, data=data, ) + await session.commit() return data diff --git a/nonebot_plugin_value/api/api_transaction.py b/nonebot_plugin_value/api/api_transaction.py index 5591a3d..c46b668 100644 --- a/nonebot_plugin_value/api/api_transaction.py +++ b/nonebot_plugin_value/api/api_transaction.py @@ -40,6 +40,7 @@ async def get_transaction_history_by_time_range( TransactionData.model_validate(transaction, from_attributes=True) for transaction in data ] + await session.commit() return result_list @@ -57,7 +58,7 @@ async def get_transaction_history( list[TransactionData]: 包含交易数据的列表 """ async with get_session() as session: - return [ + d = [ TransactionData.model_validate(transaction, from_attributes=True) for transaction in await _transaction_history( account_id, @@ -65,6 +66,8 @@ async def get_transaction_history( limit, ) ] + await session.commit() + return d async def remove_transaction(transaction_id: str) -> bool: @@ -77,7 +80,9 @@ async def remove_transaction(transaction_id: str) -> bool: bool: 是否成功删除 """ async with get_session() as session: - return await _remove_transaction( + d = await _remove_transaction( transaction_id, session, ) + await session.commit() + return d diff --git a/nonebot_plugin_value/hook/exception.py b/nonebot_plugin_value/hook/exception.py index cab9043..a3c1929 100644 --- a/nonebot_plugin_value/hook/exception.py +++ b/nonebot_plugin_value/hook/exception.py @@ -1,7 +1,7 @@ from typing import Any -class BaseException(Exception): +class BasicException(Exception): """ Base exception class for this module. """ @@ -11,13 +11,13 @@ def __init__(self, message: str = "", data: Any | None = None): self.data = data -class CancelAction(BaseException): +class CancelAction(BasicException): """ Exception raised when the user cancels an action. """ -class DataUpdate(Exception): +class DataUpdate(BasicException): """ Exception raised when the data updated """ diff --git a/nonebot_plugin_value/hook/hooks_manager.py b/nonebot_plugin_value/hook/hooks_manager.py index 6016da7..6d7359a 100644 --- a/nonebot_plugin_value/hook/hooks_manager.py +++ b/nonebot_plugin_value/hook/hooks_manager.py @@ -1,10 +1,11 @@ # 事件预处理/后处理钩子 +import asyncio from collections.abc import Awaitable, Callable from nonebot import logger from .context import TransactionComplete, TransactionContext -from .exception import CancelAction, DataUpdate +from .exception import BasicException from .hooks_type import HooksType @@ -36,7 +37,7 @@ def register( self, hook_name: str, hook_func: Callable[..., Awaitable[None]] ) -> None: """注册一个Hook""" - if hook_name not in HooksType: + if all(hook_name != hook.value for hook in HooksType.__members__.values()): raise ValueError(f"Invalid hook name: {hook_name}") self.__hooks.setdefault(hook_name, []).append(hook_func) @@ -49,10 +50,14 @@ async def run_hooks( async def _run_single_hook(hook: Callable[..., Awaitable[None]]) -> None: try: await hook(context) - except CancelAction | DataUpdate: + except BasicException: raise - except Exception: - logger.opt(exception=True).error("钩子执行失败") - - for hook in hooks: - await _run_single_hook(hook) + except Exception as e: + logger.opt(exception=e).error("钩子执行失败") + + rst: list[BaseException | None] = await asyncio.gather( + *[_run_single_hook(hook) for hook in hooks], return_exceptions=True + ) # 即使没用gather的先前实现,遇到exception中断也会抛出异常,此处只是优化性能。 + for i in rst: + if i is not None: + raise i diff --git a/nonebot_plugin_value/repositories/account.py b/nonebot_plugin_value/repositories/account.py index d912412..cf96250 100644 --- a/nonebot_plugin_value/repositories/account.py +++ b/nonebot_plugin_value/repositories/account.py @@ -24,44 +24,40 @@ def __init__(self, session: AsyncSession): async def get_or_create_account( self, user_id: str, currency_id: str ) -> UserAccount: - async with self.session as session: - """获取或创建用户账户""" - try: - # 获取货币配置 - stmt = select(CurrencyMeta).where(CurrencyMeta.id == currency_id) - result = await session.execute(stmt) - currency = result.scalar_one_or_none() - if currency is None: - raise CurrencyNotFound(f"Currency {currency_id} not found") - - # 检查账户是否存在 - stmt = ( - select(UserAccount) - .where(UserAccount.uni_id == get_uni_id(user_id, currency_id)) - .with_for_update() - ) - result = await session.execute(stmt) - account = result.scalar_one_or_none() - - if account is not None: - session.add(account) - return account - - session.add(currency) - account = UserAccount( - uni_id=get_uni_id(user_id, currency_id), - id=user_id, - currency_id=currency_id, - balance=currency.default_balance, - last_updated=datetime.now(timezone.utc), - ) - session.add(account) - await session.commit() - await session.refresh(account) - return account - except Exception: - await session.rollback() - raise + """获取或创建用户账户""" + session = self.session + # 获取货币配置 + stmt = select(CurrencyMeta).where(CurrencyMeta.id == currency_id) + result = await session.execute(stmt) + currency = result.scalar_one_or_none() + if currency is None: + raise CurrencyNotFound(f"Currency {currency_id} not found") + + # 检查账户是否存在 + stmt = ( + select(UserAccount) + .where(UserAccount.uni_id == get_uni_id(user_id, currency_id)) + .with_for_update() + ) + result = await session.execute(stmt) + account = result.scalar_one_or_none() + + if account is not None: + session.add(account) + return account + + session.add(currency) + account = UserAccount( + uni_id=get_uni_id(user_id, currency_id), + id=user_id, + currency_id=currency_id, + balance=currency.default_balance, + last_updated=datetime.now(timezone.utc), + ) + session.add(account) + await session.commit() + await session.refresh(account) + return account async def set_account_frozen( self, @@ -70,32 +66,32 @@ async def set_account_frozen( frozen: bool, ) -> None: """设置账户冻结状态""" - async with self.session as session: - try: - account = await self.get_or_create_account(account_id, currency_id) - session.add(account) - account.frozen = frozen - except Exception: - await session.rollback() - raise - else: - await session.commit() + session = self.session + try: + account = await self.get_or_create_account(account_id, currency_id) + session.add(account) + account.frozen = frozen + except Exception: + await session.rollback() + raise + else: + await session.commit() async def set_frozen_all(self, account_id: str, frozen: bool): - async with self.session as session: - try: - result = await session.execute( - select(UserAccount).where(UserAccount.id == account_id) - ) - accounts = result.scalars().all() - session.add_all(accounts) - for account in accounts: - account.frozen = frozen - except Exception as e: - await session.rollback() - raise e - else: - await session.commit() + """冻结账户的所有货币资产 + + Args: + account_id (str): 账户ID + frozen (bool): 是否冻结 + """ + session = self.session + result = await session.execute( + select(UserAccount).where(UserAccount.id == account_id) + ) + accounts = result.scalars().all() + session.add_all(accounts) + for account in accounts: + account.frozen = frozen async def is_account_frozen( self, @@ -103,8 +99,7 @@ async def is_account_frozen( currency_id: str, ) -> bool: """判断账户是否冻结""" - async with self.session: - return (await self.get_or_create_account(account_id, currency_id)).frozen + return (await self.get_or_create_account(account_id, currency_id)).frozen async def get_balance(self, account_id: str, currency_id: str) -> float | None: """获取账户余额""" @@ -115,92 +110,79 @@ async def get_balance(self, account_id: str, currency_id: str) -> float | None: async def update_balance( self, account_id: str, amount: float, currency_id: str ) -> tuple[float, float]: - async with self.session as session: - """更新余额""" - try: - # 获取账户 - account = ( - await session.execute( - select(UserAccount) - .where( - UserAccount.uni_id == get_uni_id(account_id, currency_id) - ) - .with_for_update() - ) - ).scalar_one_or_none() - - if account is None: - raise AccountNotFound("Account not found") - session.add(account) - - if account.frozen: - raise AccountFrozen( - f"Account {account_id} on currency {currency_id} is frozen" - ) - - # 获取货币规则 - currency = await session.get(CurrencyMeta, account.currency_id) - session.add(currency) - - # 负余额检查 - if amount < 0 and not getattr(currency, "allow_negative", False): - raise TransactionException("Insufficient funds") - - # 记录原始余额 - old_balance = account.balance - - # 更新余额 - account.balance = amount - await session.commit() - - return old_balance, amount - except Exception: - await session.rollback() - raise + """更新余额""" + session = self.session + # 获取账户 + account = ( + await session.execute( + select(UserAccount) + .where(UserAccount.uni_id == get_uni_id(account_id, currency_id)) + .with_for_update() + ) + ).scalar_one_or_none() + + if account is None: + raise AccountNotFound("Account not found") + session.add(account) + + if account.frozen: + raise AccountFrozen( + f"Account {account_id} on currency {currency_id} is frozen" + ) + + # 获取货币规则 + currency = await session.get(CurrencyMeta, account.currency_id) + session.add(currency) + + # 负余额检查 + if amount < 0 and not getattr(currency, "allow_negative", False): + raise TransactionException("Insufficient funds") + + # 记录原始余额 + old_balance = account.balance + + # 更新余额 + account.balance = amount + await session.commit() + + return old_balance, amount async def list_accounts( self, currency_id: str | None = None ) -> Sequence[UserAccount]: """列出所有账户""" - async with self.session as session: - if not currency_id: - result = await session.execute(select(UserAccount).with_for_update()) - else: - result = await session.execute( - select(UserAccount) - .where(UserAccount.currency_id == currency_id) - .with_for_update() - ) - data = result.scalars().all() - if len(data) > 0: - session.add_all(data) - return data + session = self.session + if not currency_id: + result = await session.execute(select(UserAccount).with_for_update()) + else: + result = await session.execute( + select(UserAccount) + .where(UserAccount.currency_id == currency_id) + .with_for_update() + ) + data = result.scalars().all() + if len(data) > 0: + session.add_all(data) + return data async def remove_account(self, account_id: str, currency_id: str | None = None): - """删除账户""" - async with self.session as session: - try: - if not currency_id: - stmt = ( - select(UserAccount) - .where(UserAccount.id == account_id) - .with_for_update() - ) - else: - stmt = ( - select(UserAccount) - .where( - UserAccount.uni_id == get_uni_id(account_id, currency_id) - ) - .with_for_update() - ) - accounts = (await session.execute(stmt)).scalars().all() - if not accounts: - raise AccountNotFound("Account not found") - for account in accounts: - stmt = delete(UserAccount).where(UserAccount.id == account.id) - await session.execute(stmt) - except Exception: - await session.rollback() - else: - await session.commit() + session = self.session + + if not currency_id: + stmt = ( + select(UserAccount) + .where(UserAccount.id == account_id) + .with_for_update() + ) + else: + stmt = ( + select(UserAccount) + .where(UserAccount.uni_id == get_uni_id(account_id, currency_id)) + .with_for_update() + ) + accounts = (await session.execute(stmt)).scalars().all() + if not accounts: + raise AccountNotFound("Account not found") + for account in accounts: + stmt = delete(UserAccount).where(UserAccount.id == account.id) + await session.execute(stmt) diff --git a/nonebot_plugin_value/repositories/currency.py b/nonebot_plugin_value/repositories/currency.py index 6e789d3..9090259 100644 --- a/nonebot_plugin_value/repositories/currency.py +++ b/nonebot_plugin_value/repositories/currency.py @@ -20,107 +20,109 @@ def __init__(self, session: AsyncSession): async def get_currency(self, currency_id: str) -> CurrencyMeta | None: """获取货币信息""" - async with self.session as session: - result = await self.session.execute( - select(CurrencyMeta).where(CurrencyMeta.id == currency_id) - ) - if currency_meta := result.scalar_one_or_none(): - session.add(currency_meta) - return currency_meta - return None + session = self.session + result = await self.session.execute( + select(CurrencyMeta).where(CurrencyMeta.id == currency_id) + ) + if currency_meta := result.scalar_one_or_none(): + session.add(currency_meta) + return currency_meta + return None async def get_currency_by_kwargs(self, **kwargs: object) -> CurrencyMeta | None: """获取货币信息""" - async with self.session as session: - result = await session.execute( - select(CurrencyMeta).where( - *( - getattr(CurrencyMeta, key) == value - for key, value in kwargs.items() - if hasattr(CurrencyMeta, key) - ) + session = self.session + result = await session.execute( + select(CurrencyMeta).where( + *( + getattr(CurrencyMeta, key) == value + for key, value in kwargs.items() + if hasattr(CurrencyMeta, key) ) ) - if currency_meta := result.scalar_one_or_none(): - session.add(currency_meta) - return currency_meta - return None + ) + if currency_meta := result.scalar_one_or_none(): + session.add(currency_meta) + return currency_meta + return None async def get_or_create_currency( self, currency_data: CurrencyData ) -> tuple[CurrencyMeta, bool]: """获取或创建货币""" - async with self.session as session: - stmt = await session.execute( - select(CurrencyMeta).where( - CurrencyMeta.id == currency_data.id, - ) + session = self.session + stmt = await session.execute( + select(CurrencyMeta).where( + CurrencyMeta.id == currency_data.id, ) - if (currency := stmt.scalars().first()) is not None: - session.add(currency) - return currency, True - result = await self.createcurrency(currency_data) - return result, False + ) + if (currency := stmt.scalars().first()) is not None: + session.add(currency) + return currency, True + result = await self.createcurrency(currency_data) + return result, False async def createcurrency(self, currency_data: CurrencyData) -> CurrencyMeta: - async with self.session as session: - """创建新货币""" - currency = CurrencyMeta(**currency_data.model_dump()) - session.add(currency) - await session.commit() - await session.refresh(currency) - return currency + """创建新货币""" + session = self.session + currency = CurrencyMeta(**currency_data.model_dump()) + session.add(currency) + await session.commit() + await session.refresh(currency) + return currency async def update_currency(self, currency_data: CurrencyData) -> CurrencyMeta: """更新货币信息""" - async with self.session as session: - try: - stmt = ( - update(CurrencyMeta) - .where(CurrencyMeta.id == currency_data.id) - .values(**dict(currency_data)) - ) - await session.execute(stmt) - await session.commit() - stmt = ( - select(CurrencyMeta) - .where(CurrencyMeta.id == currency_data.id) - .with_for_update() - ) - result = await session.execute(stmt) - currency_meta = result.scalar_one() - session.add(currency_meta) - return currency_meta - except Exception: - await session.rollback() - raise + session = self.session + + # 明确指定要更新的字段,避免包含不需要的字段(如 id, created_at 等) + update_values = { + "display_name": currency_data.display_name, + "symbol": currency_data.symbol, + "default_balance": currency_data.default_balance, + "allow_negative": currency_data.allow_negative, + } + + stmt = ( + update(CurrencyMeta) + .where(CurrencyMeta.id == currency_data.id) + .values(**update_values) + ) + await session.execute(stmt) + await session.flush() # 先 flush 确保更新生效 + + # 重新查询获取最新数据 + stmt = ( + select(CurrencyMeta) + .where(CurrencyMeta.id == currency_data.id) + .with_for_update() + ) + result = await session.execute(stmt) + currency_meta = result.scalar_one() + session.add(currency_meta) + return currency_meta async def list_currencies(self) -> Sequence[CurrencyMeta]: """列出所有货币""" - async with self.session as session: - result = await self.session.execute(select(CurrencyMeta)) - data = result.scalars().all() - session.add_all(data) - return data + session = self.session + result = await self.session.execute(select(CurrencyMeta)) + data = result.scalars().all() + session.add_all(data) + return data async def remove_currency(self, currency_id: str): """删除货币(警告!会同时删除所有关联账户!)""" - async with self.session as session: - currency = ( - await session.execute( - select(CurrencyMeta) - .where(CurrencyMeta.id == currency_id) - .with_for_update() - ) - ).scalar() - if currency is None: - raise CurrencyNotFound(f"Currency {currency_id} not found") - try: - logger.warning(f"Deleting currency {currency_id}") - stmt = delete(CurrencyMeta).where(CurrencyMeta.id == currency_id) - await session.execute(stmt) - except Exception: - await session.rollback() - raise - else: - await session.commit() + session = self.session + currency = ( + await session.execute( + select(CurrencyMeta) + .where(CurrencyMeta.id == currency_id) + .with_for_update() + ) + ).scalar() + if currency is None: + raise CurrencyNotFound(f"Currency {currency_id} not found") + + logger.warning(f"Deleting currency {currency_id}") + stmt = delete(CurrencyMeta).where(CurrencyMeta.id == currency_id) + await session.execute(stmt) diff --git a/nonebot_plugin_value/repositories/transaction.py b/nonebot_plugin_value/repositories/transaction.py index ad9e525..99601ec 100644 --- a/nonebot_plugin_value/repositories/transaction.py +++ b/nonebot_plugin_value/repositories/transaction.py @@ -28,27 +28,27 @@ async def create_transaction( balance_after: float, timestamp: datetime | None = None, ) -> Transaction: - async with self.session as session: - """创建交易记录""" - if timestamp is None: - timestamp = datetime.now(timezone.utc) - uuid = uuid1().hex - transaction_data = Transaction( - id=uuid, - account_id=account_id, - currency_id=currency_id, - amount=amount, - action=action, - source=source, - balance_before=balance_before, - balance_after=balance_after, - timestamp=timestamp, - ) - session.add(transaction_data) - await session.commit() - await session.refresh(transaction_data) - session.add(transaction_data) - return transaction_data + """创建交易记录""" + session = self.session + if timestamp is None: + timestamp = datetime.now(timezone.utc) + uuid = uuid1().hex + transaction_data = Transaction( + id=uuid, + account_id=account_id, + currency_id=currency_id, + amount=amount, + action=action, + source=source, + balance_before=balance_before, + balance_after=balance_after, + timestamp=timestamp, + ) + session.add(transaction_data) + await session.commit() + await session.refresh(transaction_data) + session.add(transaction_data) + return transaction_data async def get_transaction_history( self, account_id: str, limit: int = 100 @@ -72,38 +72,32 @@ async def get_transaction_history_by_time_range( limit: int = 100, ) -> Sequence[Transaction]: """获取账户交易历史""" - async with self.session as session: - result = await session.execute( - select(Transaction) - .where( - Transaction.account_id == account_id, - Transaction.timestamp >= start_time, - Transaction.timestamp <= end_time, - ) - .order_by(Transaction.timestamp.desc()) - .limit(limit) + session = self.session + result = await session.execute( + select(Transaction) + .where( + Transaction.account_id == account_id, + Transaction.timestamp >= start_time, + Transaction.timestamp <= end_time, ) - data = result.scalars().all() - session.add_all(data) + .order_by(Transaction.timestamp.desc()) + .limit(limit) + ) + data = result.scalars().all() + session.add_all(data) return data async def remove_transaction(self, transaction_id: str) -> None: """删除交易记录""" - async with self.session as session: - try: - transaction = ( - await session.execute( - select(Transaction) - .where(Transaction.id == transaction_id) - .with_for_update() - ) - ).scalar() - if not transaction: - raise TransactionNotFound("Transaction not found") - stmt = delete(Transaction).where(Transaction.id == transaction_id) - await session.execute(stmt) - except Exception: - await session.rollback() - raise - else: - await session.commit() + session = self.session + transaction = ( + await session.execute( + select(Transaction) + .where(Transaction.id == transaction_id) + .with_for_update() + ) + ).scalar() + if not transaction: + raise TransactionNotFound("Transaction not found") + stmt = delete(Transaction).where(Transaction.id == transaction_id) + await session.execute(stmt) diff --git a/nonebot_plugin_value/services/balance.py b/nonebot_plugin_value/services/balance.py index 09ff392..9921e06 100644 --- a/nonebot_plugin_value/services/balance.py +++ b/nonebot_plugin_value/services/balance.py @@ -1,3 +1,5 @@ +import asyncio +import contextlib from datetime import datetime, timezone from nonebot import logger @@ -28,13 +30,15 @@ async def set_frozen( currency_id (str | None, optional): 货币ID. Defaults to None. session (AsyncSession | None, optional): 异步Session. Defaults to None. """ - if session is None: - session = get_session() - async with session: + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): repo = AccountRepository(session) await repo.set_account_frozen( account_id, currency_id or DEFAULT_CURRENCY_UUID.hex, frozen ) + if not arg_session: + await session.commit() async def set_frozen_all( @@ -49,10 +53,12 @@ async def set_frozen_all( frozen (bool): 是否冻结 session (AsyncSession | None, optional): 异步Session. Defaults to None. """ - if session is None: - session = get_session() - async with session: + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): await AccountRepository(session).set_frozen_all(account_id, frozen) + if not arg_session: + await session.commit() async def del_account( @@ -67,11 +73,13 @@ async def del_account( session (AsyncSession | None, optional): 异步会话. Defaults to None. user_id (str): 用户ID """ - if session is None: - session = get_session() - async with session: + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): try: await AccountRepository(session).remove_account(account_id, currency_id) + if not arg_session: + await session.commit() return True except Exception: if fail_then_throw: @@ -93,8 +101,7 @@ async def list_accounts( """ if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex - async with session: - return await AccountRepository(session).list_accounts() + return await AccountRepository(session).list_accounts() async def get_or_create_account( @@ -112,10 +119,7 @@ async def get_or_create_account( Returns: UserAccount: 用户数据模型 """ - async with session: - return await AccountRepository(session).get_or_create_account( - user_id, currency_id - ) + return await AccountRepository(session).get_or_create_account(user_id, currency_id) async def batch_del_balance( @@ -123,7 +127,7 @@ async def batch_del_balance( currency_id: str, source: str = "batch_update", session: AsyncSession | None = None, - return_all_on_fail: bool = False, + return_all_on_fail: bool = True, ) -> list[ActionResult]: """批量减少账户余额 @@ -137,16 +141,22 @@ async def batch_del_balance( Returns: list[ActionResult]: 操作结果列表 """ - if session is None: - session = get_session() + arg_session = session + session = session or get_session() result_list: list[ActionResult] = [] - async with session: - for uid, amount in updates: - data: ActionResult = await del_balance( - uid, currency_id, amount, source, session - ) - result_list.append(data) + async def task_inner(uid: str, amount: float): + nonlocal result_list + data: ActionResult = await del_balance( + uid, currency_id, amount, source=source, session=session + ) + result_list.append(data) + + async with session.begin() if arg_session is None else contextlib.nullcontext(): + await asyncio.gather( + *[task_inner(uid, amount) for uid, amount in updates], + return_exceptions=True, + ) if not all(r.success for r in result_list): return [] if not return_all_on_fail else result_list return result_list @@ -156,8 +166,8 @@ async def del_balance( user_id: str, currency_id: str, amount: float, + session: AsyncSession, source: str = "", - session: AsyncSession | None = None, ) -> ActionResult: """异步减少余额 @@ -173,62 +183,60 @@ async def del_balance( """ if amount <= 0: return ActionResult(success=False, message="金额必须大于0") - if session is None: - session = get_session() - async with session: - account_repo = AccountRepository(session) - tx_repo = TransactionRepository(session) - account = await account_repo.get_or_create_account(user_id, currency_id) - session.add(account) - balance_before = account.balance - account_id = account.id - try: - await HooksManager().run_hooks( - HooksType.PRE.value, - TransactionContext( - user_id=user_id, - currency=currency_id, - amount=amount, - action_type=Method.WITHDRAW.value, - ), - ) - except DataUpdate as du: - amount = du.amount - except CancelAction as e: - logger.warning(f"取消了交易:{e.message}") - return TransferResult( - success=True, - message=f"取消了交易:{e.message}", - ) - balance_after = balance_before - amount - await account_repo.update_balance( - account_id, # 使用提前获取的account_id - balance_after, - currency_id, + account_repo = AccountRepository(session) + tx_repo = TransactionRepository(session) + + account = await account_repo.get_or_create_account(user_id, currency_id) + session.add(account) + balance_before = account.balance + account_id = account.id + try: + await HooksManager().run_hooks( + HooksType.PRE.value, + TransactionContext( + user_id=user_id, + currency=currency_id, + amount=amount, + action_type=Method.WITHDRAW.value, + ), ) - await tx_repo.create_transaction( - account_id, # 使用提前获取的account_id - currency_id, - amount, - Method.TRANSFER_OUT.value, - source, - balance_before, - balance_after, + except DataUpdate as du: + amount = du.amount + except CancelAction as e: + logger.warning(f"取消了交易:{e.message}") + return TransferResult( + success=True, + message=f"取消了交易:{e.message}", ) - try: - await HooksManager().run_hooks( - HooksType.POST.value, - TransactionComplete( - message="交易完成", - source_balance=balance_before, - new_balance=balance_after, - timestamp=datetime.now().timestamp(), - user_id=user_id, - ), - ) - finally: - return ActionResult(success=True, message="操作成功") + balance_after = balance_before - amount + await account_repo.update_balance( + account_id, # 使用提前获取的account_id + balance_after, + currency_id, + ) + await tx_repo.create_transaction( + account_id, # 使用提前获取的account_id + currency_id, + amount, + Method.TRANSFER_OUT.value, + source, + balance_before, + balance_after, + ) + try: + await HooksManager().run_hooks( + HooksType.POST.value, + TransactionComplete( + message="交易完成", + source_balance=balance_before, + new_balance=balance_after, + timestamp=datetime.now().timestamp(), + user_id=user_id, + ), + ) + finally: + return ActionResult(success=True, message="操作成功") async def batch_add_balance( @@ -249,10 +257,10 @@ async def batch_add_balance( Returns: list[ActionResult]: 返回的数据(与列表顺序一致,如果任意一个失败则返回空列表) """ - if session is None: - session = get_session() result_list: list[ActionResult] = [] - async with session: + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): for uid, amount in updates: data: ActionResult = await add_balance( uid, currency_id, amount, source, session @@ -282,8 +290,8 @@ async def add_balance( Returns: ActionResult: 是否成功("success"),消息说明("message") """ - session = get_session() if arg_session is None else arg_session - async with session: + session = arg_session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): if amount <= 0: return ActionResult( success=False, @@ -327,6 +335,8 @@ async def add_balance( balance_after, currency_id, ) + if not arg_session: + await session.commit() try: await HooksManager().run_hooks( HooksType.POST.value, @@ -367,13 +377,13 @@ async def transfer_funds( TransferResult: 如果成功则包含"from_balance"(源账户现在的balance),"to_balance"(目标账户现在的balance)字段 """ - session = get_session() if arg_session is None else arg_session if amount <= 0: return TransferResult( message="金额必须大于0", success=False, ) - async with session: + session = arg_session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): account_repo = AccountRepository(session) tx_repo = TransactionRepository(session) diff --git a/nonebot_plugin_value/services/currency.py b/nonebot_plugin_value/services/currency.py index d242e6a..acc566d 100644 --- a/nonebot_plugin_value/services/currency.py +++ b/nonebot_plugin_value/services/currency.py @@ -1,3 +1,4 @@ +import contextlib from collections.abc import Sequence from nonebot_plugin_orm import AsyncSession, get_session @@ -15,43 +16,50 @@ async def update_currency( Args: currency_data (CurrencyData): 货币元信息 - session (AsyncSession): 异步Session. Defaults to None. + session (AsyncSession): 异步Session. . Returns: CurrencyMeta: 货币元信息模型 """ - async with session: - return await CurrencyRepository(session).update_currency(currency_data) + return await CurrencyRepository(session).update_currency(currency_data) -async def remove_currency(currency_id: str) -> None: +async def remove_currency( + currency_id: str, session: AsyncSession | None = None +) -> None: """删除一个货币(警告!会移除关联账户!) Args: currency_id (str): 货币ID - session (AsyncSession ): 异步Session. + session (AsyncSession | None, optional): 异步Session. Defaults to None. """ - session = get_session() - async with session: + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): await CurrencyRepository(session).remove_currency(currency_id) + if not arg_session: + await session.commit() -async def list_currencies(session: AsyncSession) -> Sequence[CurrencyMeta]: +async def list_currencies( + session: AsyncSession, +) -> Sequence[CurrencyMeta]: """获取已存在的货币 Args: - session (AsyncSession): 异步Session + session (AsyncSession | None, optional): 异步Session. Defaults to None. Returns: Sequence[CurrencyMeta]: 返回货币列表 """ - async with session: - data = await CurrencyRepository(session).list_currencies() - return data + return await CurrencyRepository(session).list_currencies() -async def get_currency(currency_id: str, session: AsyncSession) -> CurrencyMeta | None: +async def get_currency( + currency_id: str, + session: AsyncSession, +) -> CurrencyMeta | None: """获取一个货币的元信息 Args: @@ -61,9 +69,8 @@ async def get_currency(currency_id: str, session: AsyncSession) -> CurrencyMeta Returns: CurrencyMeta | None: 货币元数据(不存在为None) """ - async with session: - metadata = await CurrencyRepository(session).get_currency(currency_id) - return metadata + + return await CurrencyRepository(session).get_currency(currency_id) async def get_currency_by_kwargs( @@ -79,11 +86,13 @@ async def get_currency_by_kwargs( Returns: CurrencyMeta | None: 货币元数据(不存在为None) """ - async with session: - return await CurrencyRepository(session).get_currency_by_kwargs(**kwargs) + + return await CurrencyRepository(session).get_currency_by_kwargs(**kwargs) -async def create_currency(currency_data: CurrencyData, session: AsyncSession) -> None: +async def create_currency( + currency_data: CurrencyData, session: AsyncSession | None = None +) -> None: """创建货币 Args: @@ -93,7 +102,12 @@ async def create_currency(currency_data: CurrencyData, session: AsyncSession) -> Returns: CurrencyMeta: 创建的货币元数据 """ - await CurrencyRepository(session).createcurrency(currency_data) + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): + await CurrencyRepository(session).createcurrency(currency_data) + if not arg_session: + await session.commit() async def get_or_create_currency( @@ -110,9 +124,8 @@ async def get_or_create_currency( tuple[CurrencyMeta, bool] 元数据和是否创建 """ - async with session: - repo = CurrencyRepository(session) - return await repo.get_or_create_currency(currency_data) + repo = CurrencyRepository(session) + return await repo.get_or_create_currency(currency_data) async def get_default_currency(session: AsyncSession) -> CurrencyMeta: @@ -124,9 +137,9 @@ async def get_default_currency(session: AsyncSession) -> CurrencyMeta: Returns: CurrencyMeta: 货币元数据 """ - async with session: - return ( - await get_or_create_currency( - CurrencyData(id=DEFAULT_CURRENCY_UUID.hex), session - ) - )[0] + + return ( + await get_or_create_currency( + CurrencyData(id=DEFAULT_CURRENCY_UUID.hex), session + ) + )[0] diff --git a/nonebot_plugin_value/services/transaction.py b/nonebot_plugin_value/services/transaction.py index c191c2b..32cfeee 100644 --- a/nonebot_plugin_value/services/transaction.py +++ b/nonebot_plugin_value/services/transaction.py @@ -1,6 +1,7 @@ +import contextlib from datetime import datetime -from nonebot_plugin_orm import AsyncSession +from nonebot_plugin_orm import AsyncSession, get_session from ..repository import TransactionRepository @@ -19,7 +20,7 @@ async def get_transaction_history_by_time_range( start_time (datetime): 起始时间 end_time (datetime): 结束时间 limit (int, optional): 条数限制. Defaults to 100. - session (AsyncSession): 会话. + session (AsyncSession | None, optional): 会话. Defaults to None. Returns: Sequence[Transaction]: 记录 @@ -54,7 +55,7 @@ async def get_transaction_history( async def remove_transaction( transaction_id: str, - session: AsyncSession, + session: AsyncSession | None = None, fail_then_throw: bool = False, ) -> bool: """删除交易记录 @@ -67,9 +68,13 @@ async def remove_transaction( Returns: bool: 是否成功 """ - async with session: + arg_session = session + session = session or get_session() + async with session if arg_session is None else contextlib.nullcontext(): try: await TransactionRepository(session).remove_transaction(transaction_id) + if not arg_session: + await session.commit() return True except Exception: if fail_then_throw: diff --git a/pyproject.toml b/pyproject.toml index ae2651d..5046bed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nonebot-plugin-value" -version = "0.1.7" +version = "1.0.0" description = "Economy API for NoneBot2" readme = "README.md" requires-python = ">=3.10, <4.0.0" @@ -28,6 +28,7 @@ dev-dependencies = [ "nonebot2[fastapi]>=2.4.2", "nonebug>=0.4.3", "pytest-asyncio>=1.1.0", + "pytest-cov>=7.0.0", ] [tool.uv.pip] @@ -73,3 +74,31 @@ asyncio_default_fixture_loop_scope = "session" [tool.nonebot] plugins = ["nonebot_plugin_value"] + +# Coverage Configuration +[tool.coverage.run] +source = ["nonebot_plugin_value"] +omit = [ + "*/migrations/*", + "*/tests/*", + "*/__pycache__/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", +] +ignore_errors = true +show_missing = true + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" diff --git a/run_test.sh b/run_test.sh new file mode 100755 index 0000000..7f80957 --- /dev/null +++ b/run_test.sh @@ -0,0 +1,3 @@ +rm data.sqlite3 +nb orm upgrade +uv run pytest tests/* --cov=nonebot_plugin_value --cov-report=term-missing --cov-report=xml --junitxml=test-results.xml -v diff --git a/tests/balance_transfer.py b/tests/balance_transfer.py new file mode 100644 index 0000000..8c25fb6 --- /dev/null +++ b/tests/balance_transfer.py @@ -0,0 +1,46 @@ +import pytest +from nonebug import App # type:ignore + + +@pytest.mark.asyncio +async def test_transfer(app: App): + from nonebot_plugin_value.api.api_balance import ( + add_balance, + get_or_create_account, + transfer_funds, + ) + from nonebot_plugin_value.uuid_lib import to_uuid + + u1 = to_uuid("u1") + u2 = to_uuid("u2") + + # 创建账户 + account1 = await get_or_create_account(u1) + account2 = await get_or_create_account(u2) + assert account1.balance == 0.0, ( + f"账户 u1 初始余额应为 0.0,实际为 {account1.balance}" + ) + assert account2.balance == 0.0, ( + f"账户 u2 初始余额应为 0.0,实际为 {account2.balance}" + ) + + # 添加余额 + await add_balance(u1, 100) + account1_after_add = await get_or_create_account(u1) + assert account1_after_add.balance == 100.0, ( + f"充值后 u1 余额应为 100.0,实际为 {account1_after_add.balance}" + ) + + # 转账 + await transfer_funds(u1, u2, 50) + + # 验证转账后的余额 + account1_after_transfer = await get_or_create_account(u1) + account2_after_transfer = await get_or_create_account(u2) + + assert account1_after_transfer.balance == 50.0, ( + f"转账后 u1 余额应为 50.0,实际为 {account1_after_transfer.balance}" + ) + assert account2_after_transfer.balance == 50.0, ( + f"转账后 u2 余额应为 50.0,实际为 {account2_after_transfer.balance}" + ) diff --git a/tests/batch_run.py b/tests/batch_run.py new file mode 100644 index 0000000..ad0f650 --- /dev/null +++ b/tests/batch_run.py @@ -0,0 +1,99 @@ +import pytest +from nonebug import App # type:ignore + + +@pytest.mark.asyncio +async def test_batch_operations(app: App): + from nonebot_plugin_value.api.api_balance import ( + batch_add_balance, + batch_del_balance, + del_account, + get_or_create_account, + list_accounts, + ) + from nonebot_plugin_value.api.api_currency import ( + CurrencyData, + get_or_create_currency, + list_currencies, + ) + from nonebot_plugin_value.uuid_lib import to_uuid + + ac_uid: list[str] = [to_uuid(f"account{i}") for i in range(10)] + cu_id = to_uuid("currency") + cu_data = CurrencyData(id=cu_id, display_name="currency", symbol="C") + await get_or_create_currency(cu_data) + for uid in ac_uid: + await get_or_create_account(uid) + await get_or_create_account(uid, cu_id) + + # 批量添加余额(默认货币) + await batch_add_balance( + [(uid, 1) for uid in ac_uid], + ) + + # 验证默认货币余额是否都成功增加了 1 + for uid in ac_uid: + account = await get_or_create_account(uid) + assert account.balance == 1.0, ( + f"账户 {uid} 的默认货币余额应为 1.0,实际为 {account.balance}" + ) + + # 批量添加余额(指定货币) + await batch_add_balance( + [(uid, 1) for uid in ac_uid], + currency_id=cu_id, + ) + + # 验证指定货币余额是否都成功增加了 1 + for uid in ac_uid: + account = await get_or_create_account(uid, cu_id) + assert account.balance == 1.0, ( + f"账户 {uid} 的指定货币余额应为 1.0,实际为 {account.balance}" + ) + + # 再次批量添加以验证累加 + await batch_add_balance( + [(uid, 2) for uid in ac_uid], + ) + + # 验证默认货币余额是否都成功累加到 3.0 (1 + 2) + for uid in ac_uid: + account = await get_or_create_account(uid) + assert account.balance == 3.0, ( + f"账户 {uid} 的默认货币余额应为 3.0,实际为 {account.balance}" + ) + + # 批量减少余额 + await batch_del_balance( + [(uid, 1) for uid in ac_uid], + ) + + # 验证默认货币余额是否都成功减少到 2.0 (3 - 1) + for uid in ac_uid: + account = await get_or_create_account(uid) + assert account.balance == 2.0, ( + f"账户 {uid} 的默认货币余额应为 2.0,实际为 {account.balance}" + ) + + # 批量减少余额(指定货币) + await batch_del_balance( + [(uid, 1) for uid in ac_uid], + currency_id=cu_id, + ) + + # 验证指定货币余额是否都成功减少到 0.0 (1 - 1) + for uid in ac_uid: + account = await get_or_create_account(uid, cu_id) + assert account.balance == 0.0, ( + f"账户 {uid} 的指定货币余额应为 0.0,实际为 {account.balance}" + ) + + # 测试列表功能 + await list_accounts() + + # 删除所有账户并验证删除成功 + delete_results = [await del_account(uid) for uid in ac_uid] + assert all(delete_results), "所有账户应该被成功删除" + + # 验证货币列表功能 + await list_currencies() diff --git a/tests/value_tests/cache_test.py b/tests/cache_test.py similarity index 59% rename from tests/value_tests/cache_test.py rename to tests/cache_test.py index bc705ff..371d051 100644 --- a/tests/value_tests/cache_test.py +++ b/tests/cache_test.py @@ -14,6 +14,7 @@ async def test_cache(app: App): TEST_TRANSACTION = TransactionData(id="1", source="test", amount=1, currency_id="1") cache_manager = CacheManager() + # 测试缓存更新 await cache_manager.update_cache( category=CacheCategoryEnum.CURRENCY, data=TEST_CURRENCY, @@ -26,33 +27,45 @@ async def test_cache(app: App): category=CacheCategoryEnum.TRANSACTION, data=TEST_TRANSACTION, ) - assert ( - await (await cache_manager.get_cache(CacheCategoryEnum.CURRENCY)).get( - data_id="1" - ) - == TEST_CURRENCY - ) - assert ( - await (await cache_manager.get_cache(CacheCategoryEnum.ACCOUNT)).get( - data_id="1" - ) - ) == TEST_USER - assert ( - await (await cache_manager.get_cache(CacheCategoryEnum.TRANSACTION)).get( - data_id="1" - ) - == TEST_TRANSACTION + + # 验证缓存命中 + currency_cache = await ( + await cache_manager.get_cache(CacheCategoryEnum.CURRENCY) + ).get(data_id="1") + assert currency_cache == TEST_CURRENCY, "货币缓存应该与测试数据一致" + + user_cache = await (await cache_manager.get_cache(CacheCategoryEnum.ACCOUNT)).get( + data_id="1" ) + assert user_cache == TEST_USER, "用户缓存应该与测试数据一致" + + transaction_cache = await ( + await cache_manager.get_cache(CacheCategoryEnum.TRANSACTION) + ).get(data_id="1") + assert transaction_cache == TEST_TRANSACTION, "交易缓存应该与测试数据一致" + + # 测试缓存过期 await cache_manager.expire_cache(category=CacheCategoryEnum.CURRENCY) assert not await (await cache_manager.get_cache(CacheCategoryEnum.CURRENCY)).get( data_id="1" - ) + ), "货币缓存过期后应该不存在" + await cache_manager.expire_cache(category=CacheCategoryEnum.ACCOUNT) assert not await (await cache_manager.get_cache(CacheCategoryEnum.ACCOUNT)).get( data_id="1" - ) + ), "用户缓存过期后应该不存在" + await cache_manager.expire_cache(category=CacheCategoryEnum.TRANSACTION) assert not await (await cache_manager.get_cache(CacheCategoryEnum.TRANSACTION)).get( data_id="1" + ), "交易缓存过期后应该不存在" + + # 测试清空所有缓存 + await cache_manager.update_cache( + category=CacheCategoryEnum.CURRENCY, + data=TEST_CURRENCY, ) await cache_manager.expire_all_cache() + assert not await (await cache_manager.get_cache(CacheCategoryEnum.CURRENCY)).get( + data_id="1" + ), "清空所有缓存后应该不存在" diff --git a/tests/conftest.py b/tests/conftest.py index 8415720..993604a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest from pytest_asyncio import is_async_test -# 将项目根目录添加到sys.path中 +# 将项目根目录添加到 sys.path 中 project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root)) diff --git a/tests/create_currency.py b/tests/create_currency.py new file mode 100644 index 0000000..080beb2 --- /dev/null +++ b/tests/create_currency.py @@ -0,0 +1,69 @@ +import pytest +from nonebug import App # type: ignore + + +@pytest.mark.asyncio +async def test_new_currency(app: App): + from nonebot_plugin_value.api.api_balance import ( + add_balance, + del_account, + del_balance, + get_or_create_account, + ) + from nonebot_plugin_value.api.api_currency import ( + CurrencyData, + get_currency, + get_or_create_currency, + remove_currency, + ) + from nonebot_plugin_value.uuid_lib import to_uuid + + c_id = "114514" + u_id = to_uuid("114514") + + # 创建货币 + currency = CurrencyData(id=c_id, display_name="test", symbol="t") + created_currency = await get_or_create_currency(currency) + assert created_currency.id == c_id, ( + f"创建的货币 ID 应为 {c_id},实际为 {created_currency.id}" + ) + assert created_currency.display_name == "test", ( + f"货币名称应为 'test',实际为 {created_currency.display_name}" + ) + + # 验证货币存在 + currency_data = await get_currency(c_id) + assert currency_data is not None, "货币应该存在" + assert currency_data.id == c_id, "获取的货币 ID 应该与创建的一致" + + # 创建账户 + account = await get_or_create_account(u_id, c_id) + assert account.currency_id == c_id, ( + f"账户货币 ID 应为 {c_id},实际为 {account.currency_id}" + ) + assert account.balance == 0.0, f"账户初始余额应为 0.0,实际为 {account.balance}" + + # 添加余额 + await add_balance(u_id, 114514, "1919810", c_id) + account_after_add = await get_or_create_account(u_id, c_id) + assert account_after_add.balance == 114514.0, ( + f"充值后余额应为 114514.0,实际为 {account_after_add.balance}" + ) + + # 减少余额 + await del_balance(u_id, 114514, "1919810", c_id) + account_after_del = await get_or_create_account(u_id, c_id) + assert account_after_del.balance == 0.0, ( + f"扣减后余额应为 0.0,实际为 {account_after_del.balance}" + ) + + # 删除账户 + delete_result = await del_account(u_id, c_id) + assert delete_result, "账户应该被成功删除" + + # 删除货币 + await remove_currency(c_id) + + # 验证货币不存在 + currency_after_remove = await get_currency(c_id) + assert currency_after_remove is None, "货币删除后应该不存在" diff --git a/tests/default_account.py b/tests/default_account.py new file mode 100644 index 0000000..b105d9d --- /dev/null +++ b/tests/default_account.py @@ -0,0 +1,47 @@ +import pytest +from nonebug import App # type: ignore + + +@pytest.mark.asyncio +async def test_balance(app: App): + from nonebot_plugin_value.api.api_balance import ( + add_balance, + del_account, + del_balance, + get_or_create_account, + ) + from nonebot_plugin_value.uuid_lib import to_uuid + + account = to_uuid("Test_Example") + + # 创建账户并验证初始状态 + created_account = await get_or_create_account(account) + assert created_account.balance == 0.0, ( + f"账户初始余额应为 0.0,实际为 {created_account.balance}" + ) + + # 添加余额 + await add_balance( + account, + 100, + "add", + ) + account_after_add = await get_or_create_account(account) + assert account_after_add.balance == 100.0, ( + f"充值后余额应为 100.0,实际为 {account_after_add.balance}" + ) + + # 减少余额 + await del_balance( + account, + 100, + "del", + ) + account_after_del = await get_or_create_account(account) + assert account_after_del.balance == 0.0, ( + f"扣减后余额应为 0.0,实际为 {account_after_del.balance}" + ) + + # 删除账户并验证 + delete_result = await del_account(account) + assert delete_result, "账户应该被成功删除" diff --git a/tests/depends.py b/tests/depends.py new file mode 100644 index 0000000..0bc1ffa --- /dev/null +++ b/tests/depends.py @@ -0,0 +1,60 @@ +import pytest +from nonebug import App # type: ignore + + +def make_event(): + from nonebot.adapters.onebot.v11 import Message, MessageEvent + from nonebot.adapters.onebot.v11.event import Sender + + return MessageEvent( + time=0, + self_id=0, + user_id=123456789, + post_type="message", + message=Message(""), + sub_type="friend", + message_type="private", + message_id=0, + raw_message="", + font=0, + sender=Sender(), + original_message=Message(), + ) + + +@pytest.mark.asyncio +async def test_depends(app: App): + from nonebot_plugin_value.api.api_balance import ( + del_account, + get_or_create_account, + ) + from nonebot_plugin_value.api.depends.factory import DependsSwitch + from nonebot_plugin_value.uuid_lib import to_uuid + + uid = to_uuid("123456789") + + # 创建账户并验证初始状态 + account = await get_or_create_account(uid) + assert account.balance == 0.0, f"账户初始余额应为 0.0,实际为 {account.balance}" + + # 使用依赖注入获取执行器 + executor = await (DependsSwitch().account_executor())(make_event()) + + # 添加余额 + await executor.add_balance(100) + + # 验证余额(通过执行器) + balance_via_executor = await executor.get_balance() + assert balance_via_executor == 100.0, ( + f"通过执行器获取的余额应为 100.0,实际为 {balance_via_executor}" + ) + + # 再次验证余额(通过 API) + account_after_add = await get_or_create_account(uid) + assert account_after_add.balance == 100.0, ( + f"通过 API 获取的余额应为 100.0,实际为 {account_after_add.balance}" + ) + + # 清理账户 + delete_result = await del_account(uid) + assert delete_result, "账户应该被成功删除" diff --git a/tests/test_api_coverage.py b/tests/test_api_coverage.py new file mode 100644 index 0000000..58baae9 --- /dev/null +++ b/tests/test_api_coverage.py @@ -0,0 +1,155 @@ +import asyncio + +import pytest +from nonebug import App # type:ignore + + +@pytest.mark.asyncio +async def test_currency_api(app: App): + """测试货币 API 的完整功能""" + from nonebot_plugin_value.api.api_currency import ( + create_currency, + get_currency, + get_currency_by_kwargs, + get_default_currency, + list_currencies, + remove_currency, + update_currency, + ) + from nonebot_plugin_value.pyd_models.currency_pyd import CurrencyData + from nonebot_plugin_value.uuid_lib import to_uuid + + # 测试获取默认货币 + default_currency = await get_default_currency() + assert default_currency is not None, "应该存在默认货币" + assert default_currency.id is not None, "默认货币应该有 ID" + + # 测试创建新货币 + test_currency_id = to_uuid("test_api_currency") + new_currency_data = CurrencyData( + id=test_currency_id, display_name="Test API Currency", symbol="TAC" + ) + await create_currency(new_currency_data) + + # 验证创建成功 + currency = await get_currency(test_currency_id) + assert currency is not None, "货币应该被创建" + assert currency.display_name == "Test API Currency", "货币名称应该匹配" + assert currency.symbol == "TAC", "货币符号应该匹配" + + # 测试通过 kwargs 查询 + currency_by_kwargs = await get_currency_by_kwargs(display_name="Test API Currency") + assert currency_by_kwargs is not None, "应该能通过 kwargs 查询到货币" + assert currency_by_kwargs.id == test_currency_id, "查询结果应该匹配" + + # 测试不存在的 kwargs 查询 + not_found = await get_currency_by_kwargs(display_name="Non Existent Currency") + assert not_found is None, "不存在的货币应该返回 None" + + # 测试更新货币 + updated_currency_data = CurrencyData( + id=test_currency_id, display_name="Updated Test Currency", symbol="UTC" + ) + updated_currency = await update_currency(updated_currency_data) + assert updated_currency.display_name == "Updated Test Currency", ( + "货币名称应该被更新" + ) + assert updated_currency.symbol == "UTC", "货币符号应该被更新" + + # 验证更新持久化(强制从数据库读取,不使用缓存) + currency_after_update = await get_currency(test_currency_id, no_cache=True) + assert currency_after_update is not None, "更新后的货币应该存在" + assert currency_after_update.display_name == "Updated Test Currency", ( + "更新应该持久化" + ) + # 测试获取所有货币 + currencies = await list_currencies() + assert len(currencies) >= 2, "应该至少有两个货币(默认 + 测试)" + + # 测试 no_cache_update 参数 + currencies_no_cache = await list_currencies(no_cache_update=True) + assert len(currencies_no_cache) >= 2, "即使不更新缓存也应该返回数据" + + # 测试缓存命中 + cached_currency = await get_currency(test_currency_id, no_cache=False) + assert cached_currency is not None, "应该能从缓存获取货币" + + # 测试 no_cache 参数(强制不使用缓存) + no_cache_currency = await get_currency(test_currency_id, no_cache=True) + assert no_cache_currency is not None, "即使不使用缓存也应该能获取货币" + + # 测试删除货币 + await remove_currency(test_currency_id) + + # 验证删除成功 + deleted_currency = await get_currency(test_currency_id) + assert deleted_currency is None, "货币应该被删除" + + # 测试获取不存在的货币 + non_existent = await get_currency(to_uuid("non_existent")) + assert non_existent is None, "不存在的货币应该返回 None" + + +@pytest.mark.asyncio +async def test_transaction_api(app: App): + """测试交易记录 API""" + import time + + from nonebot_plugin_value.api.api_balance import add_balance, get_or_create_account + from nonebot_plugin_value.api.api_transaction import ( + get_transaction_history, + get_transaction_history_by_time_range, + remove_transaction, + ) + from nonebot_plugin_value.uuid_lib import to_uuid + + test_user_id = to_uuid("test_transaction_api_user") + + # 创建账户 + await get_or_create_account(test_user_id) + + # 添加多笔余额以创建交易记录 + for i in range(5): + await add_balance(test_user_id, 10.0 * (i + 1), f"test_add_{i}") + + # 等待一小段时间以确保时间戳不同 + await asyncio.sleep(0.01) + timestamp_before_sleep = time.time() + await asyncio.sleep(0.01) + + # 获取交易历史(限制数量) + transactions_limited = await get_transaction_history(test_user_id, limit=3) + assert len(transactions_limited) <= 3, "交易记录数量不应该超过限制" + + # 获取更多交易记录 + transactions_all = await get_transaction_history(test_user_id, limit=100) + assert len(transactions_all) >= 5, "应该至少有 5 条交易记录" + + # 验证交易记录的属性 + for tx in transactions_all: + assert tx.amount > 0, "充值金额应该大于 0" + assert tx.account_id == test_user_id, "交易记录应该属于正确的账户" + assert tx.source.startswith("test_add_"), "交易来源应该匹配" + + # 测试按时间范围查询 + transactions_by_time = await get_transaction_history_by_time_range( + account_id=test_user_id, + start_time=0, # 从 Unix 纪元开始 + end_time=timestamp_before_sleep + 10, # 到当前时间之后 + limit=10, + ) + assert len(transactions_by_time) >= 5, "应该能找到至少 5 条交易记录" + + # 测试空的时间范围 + empty_transactions = await get_transaction_history_by_time_range( + account_id=test_user_id, + start_time=9999999999, # 未来时间 + end_time=9999999999, + limit=10, + ) + assert len(empty_transactions) == 0, "未来时间范围应该没有交易记录" + + # 测试删除交易记录(可选,如果需要测试的话) + if len(transactions_all) > 0: + first_tx_id = transactions_all[0].id + await remove_transaction(first_tx_id) diff --git a/tests/test_dump_tools.py b/tests/test_dump_tools.py new file mode 100644 index 0000000..1e31dc9 --- /dev/null +++ b/tests/test_dump_tools.py @@ -0,0 +1,129 @@ +import json +import tempfile +from pathlib import Path + +import aiofiles +import pytest +from nonebug import App # type:ignore + + +@pytest.mark.asyncio +async def test_dump_and_migrate(app: App): + """测试数据导出和导入功能""" + from nonebot_plugin_value.api.api_balance import ( + add_balance, + get_or_create_account, + ) + from nonebot_plugin_value.api.api_currency import ( + CurrencyData, + get_or_create_currency, + ) + from nonebot_plugin_value.dump_tools import ( + dump_data, + dump_data_to_json_file, + migrate_from_data, + migrate_from_json_file, + ) + from nonebot_plugin_value.uuid_lib import to_uuid + + # 创建测试数据 + test_currency_id = to_uuid("test_currency") + test_user_id = to_uuid("test_user") + + # 创建货币 + currency_data = CurrencyData( + id=test_currency_id, display_name="Test Currency", symbol="TC" + ) + await get_or_create_currency(currency_data) + + # 创建账户并添加余额 + await get_or_create_account(test_user_id, test_currency_id) + await add_balance(test_user_id, 100.0, "test_add", test_currency_id) + + # 验证初始状态 + account_before = await get_or_create_account(test_user_id, test_currency_id) + assert account_before.balance == 100.0 + + # 测试导出功能 + exported_data = await dump_data() + assert len(exported_data.currencies) >= 1, "应该至少有一个货币" + assert len(exported_data.accounts) >= 1, "应该至少有一个账户" + + # 验证导出的数据包含我们的测试数据 + exported_currency_ids = [c.id for c in exported_data.currencies] + assert test_currency_id in exported_currency_ids, "导出的货币应该包含测试货币" + + exported_account_ids = [a.account_data.id for a in exported_data.accounts] + assert test_user_id in exported_account_ids, "导出的账户应该包含测试账户" + + # 测试导出到 JSON 文件 + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + await dump_data_to_json_file(tmp_path) + + # 验证文件存在 + json_file = tmp_path / "migration.json" + assert json_file.exists(), "JSON 文件应该被创建" + + # 验证文件内容(使用异步方式读取) + async with aiofiles.open(json_file, encoding="utf-8") as f: + file_content = await f.read() + file_data = json.loads(file_content) + assert "currencies" in file_data, "JSON 应该包含 currencies 字段" + assert "accounts" in file_data, "JSON 应该包含 accounts 字段" + + # 测试从数据模型迁移(修改余额) + await add_balance(test_user_id, 50.0, "test_add2", test_currency_id) + account_after_add = await get_or_create_account(test_user_id, test_currency_id) + assert account_after_add.balance == 150.0, "充值后余额应为 150.0" + + # 重新导出当前状态 + current_data = await dump_data() + + # 修改导出数据中的余额 + for account in current_data.accounts: + if account.account_data.id == test_user_id: + account.account_data.balance = 200.0 + for tx in account.transactions: + if tx.amount == 50.0: + tx.amount = 100.0 # 修改交易记录 + + # 执行迁移 + await migrate_from_data(current_data) + + # 验证迁移后的余额 + account_after_migrate = await get_or_create_account(test_user_id, test_currency_id) + assert account_after_migrate.balance == 200.0, "迁移后余额应为 200.0" + + # 测试从 JSON 文件迁移 + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + json_file = tmp_path / "migration.json" + + # 创建新的测试数据 + test_user2_id = to_uuid("test_user2") + await get_or_create_account(test_user2_id, test_currency_id) + await add_balance(test_user2_id, 75.0, "test_user2_add", test_currency_id) + + # 导出并修改 + data_to_export = await dump_data() + async with aiofiles.open(json_file, "w", encoding="utf-8") as f: + await f.write(data_to_export.model_dump_json(indent=4)) + + # 修改余额为 300 + for account in data_to_export.accounts: + if account.account_data.id == test_user2_id: + account.account_data.balance = 300.0 + + # 写入修改后的数据 + async with aiofiles.open(json_file, "w", encoding="utf-8") as f: + await f.write(data_to_export.model_dump_json(indent=4)) + + # 从文件迁移 + await migrate_from_json_file(json_file) + + # 验证迁移结果 + account_after_file_migrate = await get_or_create_account( + test_user2_id, test_currency_id + ) + assert account_after_file_migrate.balance == 300.0, "从文件迁移后余额应为 300.0" diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..1d0d9f2 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,145 @@ +import pytest +from nonebug import App # type:ignore + + +@pytest.mark.asyncio +async def test_hooks_manager(app: App): + """测试钩子管理器功能""" + from nonebot_plugin_value.hook.context import ( + TransactionComplete, + TransactionContext, + ) + from nonebot_plugin_value.hook.exception import CancelAction, DataUpdate + from nonebot_plugin_value.hook.hooks_manager import HooksManager + from nonebot_plugin_value.hook.hooks_type import HooksType + + # 获取单例实例 + manager = HooksManager() + + # 测试 Hook 类型枚举 + assert HooksType.pre() == "vault_pre_transaction" + assert HooksType.post() == "vault_post_transaction" + assert len(HooksType.methods()) == 2 + assert "vault_pre_transaction" in HooksType.methods() + assert "vault_post_transaction" in HooksType.methods() + + # 清理可能存在的旧钩子 + setattr(manager, "_HooksManager__hooks", {}) + + # 测试注册钩子 + pre_hook_called = False + post_hook_called = False + + async def pre_transaction_hook(context: TransactionContext): + nonlocal pre_hook_called + pre_hook_called = True + assert getattr(context, "user_id") == "test_user" + assert getattr(context, "currency") == "test_currency" + assert getattr(context, "amount") == 100.0 + assert getattr(context, "action_type") == "add_balance" + + async def post_transaction_hook(context: TransactionComplete): + nonlocal post_hook_called + post_hook_called = True + assert getattr(context, "message") == "Transaction completed" + assert getattr(context, "source_balance") == 0.0 + assert getattr(context, "new_balance") == 100.0 + assert getattr(context, "user_id") == "test_user" + + # 使用 register 方法注册 + manager.register(HooksType.pre(), pre_transaction_hook) + manager.register(HooksType.post(), post_transaction_hook) + + # 验证钩子已注册 + hooks_dict = getattr(manager, "_HooksManager__hooks") + assert HooksType.pre() in hooks_dict + assert HooksType.post() in hooks_dict + assert len(hooks_dict[HooksType.pre()]) == 1 + assert len(hooks_dict[HooksType.post()]) == 1 + + # 测试运行 PRE 钩子 + pre_context = TransactionContext( + user_id="test_user", + currency="test_currency", + amount=100.0, + action_type="add_balance", + ) + await manager.run_hooks(HooksType.pre(), pre_context) + assert pre_hook_called, "PRE 钩子应该被调用" + + # 测试运行 POST 钩子 + post_context = TransactionComplete( + message="Transaction completed", + source_balance=0.0, + new_balance=100.0, + user_id="test_user", + ) + await manager.run_hooks(HooksType.post(), post_context) + assert post_hook_called, "POST 钩子应该被调用" + + # 测试装饰器方式注册 + decorator_hook_called = False + + @manager.on_event(HooksType.pre()) + async def decorator_hook(context: TransactionContext): # type: ignore[reportUnusedFunction] + """装饰器方式注册的钩子函数""" + nonlocal decorator_hook_called + decorator_hook_called = True + + # 验证装饰器注册的钩子存在 + hooks_dict = getattr(manager, "_HooksManager__hooks") + assert len(hooks_dict[HooksType.pre()]) == 2 + + # 运行所有 PRE 钩子(包括装饰器注册的) + pre_hook_called = False + decorator_hook_called = False + await manager.run_hooks(HooksType.pre(), pre_context) + assert pre_hook_called, "第一个 PRE 钩子应该被调用" + assert decorator_hook_called, "装饰器注册的 PRE 钩子应该被调用" + + # 测试无效钩子名称 + with pytest.raises(ValueError, match="Invalid hook name"): + manager.register("invalid_hook_name", pre_transaction_hook) + + # 测试取消操作 + async def cancel_hook(context: TransactionContext): + context.cancel("Test cancel reason") + + setattr(manager, "_HooksManager__hooks", {}) + manager.register(HooksType.pre(), cancel_hook) + + with pytest.raises(CancelAction) as exc_info: + await manager.run_hooks(HooksType.pre(), pre_context) + + assert str(exc_info.value) == "Test cancel reason" + + # 测试数据更新 + async def update_hook(context: TransactionContext): + context.commit_update() + + setattr(manager, "_HooksManager__hooks", {}) + manager.register(HooksType.pre(), update_hook) + + with pytest.raises(DataUpdate) as exc_info: + await manager.run_hooks(HooksType.pre(), pre_context) + + assert getattr(exc_info.value, "amount") == 100.0 + + # 测试不存在的钩子(应该直接返回,不执行任何操作) + setattr(manager, "_HooksManager__hooks", {}) + await manager.run_hooks(HooksType.pre(), pre_context) # 不应该抛出异常 + + # 测试钩子执行异常处理 + error_hook_called = False + + async def error_hook(context: TransactionContext): + nonlocal error_hook_called + error_hook_called = True + raise ValueError("Test error in hook") + + setattr(manager, "_HooksManager__hooks", {}) + manager.register(HooksType.pre(), error_hook) + + # 应该记录错误但不中断执行 + await manager.run_hooks(HooksType.pre(), pre_context) + assert error_hook_called, "错误的钩子也应该被调用" diff --git a/tests/transaction_histories.py b/tests/transaction_histories.py new file mode 100644 index 0000000..7f97ee7 --- /dev/null +++ b/tests/transaction_histories.py @@ -0,0 +1,47 @@ +import pytest +from nonebug import App # type: ignore + + +@pytest.mark.asyncio +async def test_transactions(app: App): + from nonebot_plugin_value.api.api_balance import add_balance, get_or_create_account + from nonebot_plugin_value.api.api_transaction import get_transaction_history + from nonebot_plugin_value.uuid_lib import to_uuid + + account_id = to_uuid("123") + + # 创建账户并验证初始状态 + account = await get_or_create_account(account_id) + assert account.balance == 0.0, f"账户初始余额应为 0.0,实际为 {account.balance}" + + # 多次添加余额 + for i in range(20): + await add_balance(account_id, 1) + + # 验证最终余额 + account_after_adds = await get_or_create_account(account_id) + assert account_after_adds.balance == 20.0, ( + f"20 次充值后余额应为 20.0,实际为 {account_after_adds.balance}" + ) + + # 获取交易历史 + transactions = await get_transaction_history(account.id, 30) + + # 验证交易记录数量和完整性 + assert len(transactions) >= 20, ( + f"交易记录数量应该至少 20 条,实际为 {len(transactions)} 条" + ) + + # 验证每笔交易的金额(应该是 1) + for i, transaction in enumerate(transactions[:20]): + assert transaction.amount == 1, ( + f"第 {i + 1} 笔交易金额应为 1,实际为 {transaction.amount}" + ) + + # 验证交易记录的连续性(可选) + if len(transactions) > 1: + # 检查交易记录是否按时间倒序排列(最新的在前) + for i in range(len(transactions) - 1): + assert transactions[i].timestamp >= transactions[i + 1].timestamp, ( + "交易记录应该按时间倒序排列" + ) diff --git a/tests/value_tests/balance_transfer.py b/tests/value_tests/balance_transfer.py deleted file mode 100644 index d98571b..0000000 --- a/tests/value_tests/balance_transfer.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest -from nonebug import App # type:ignore - - -@pytest.mark.asyncio -async def test_transfer(app: App): - from nonebot_plugin_value.api.api_balance import ( - add_balance, - get_or_create_account, - transfer_funds, - ) - from nonebot_plugin_value.uuid_lib import to_uuid - - u1 = to_uuid("u1") - u2 = to_uuid("u2") - await get_or_create_account(u1) - await get_or_create_account(u2) - await add_balance(u1, 100) - await transfer_funds(u1, u2, 50) diff --git a/tests/value_tests/batch_run.py b/tests/value_tests/batch_run.py deleted file mode 100644 index 10a6918..0000000 --- a/tests/value_tests/batch_run.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest -from nonebug import App # type:ignore - - -@pytest.mark.asyncio -async def test_batch_operations(app: App): - from nonebot_plugin_value.api.api_balance import ( - batch_add_balance, - batch_del_balance, - del_account, - get_or_create_account, - list_accounts, - ) - from nonebot_plugin_value.api.api_currency import ( - CurrencyData, - get_or_create_currency, - list_currencies, - ) - from nonebot_plugin_value.uuid_lib import to_uuid - - ac_uid: list[str] = [to_uuid(f"account{i}") for i in range(100)] - cu_id = to_uuid("currency") - cu_data = CurrencyData(id=cu_id, display_name="currency", symbol="C") - await get_or_create_currency(cu_data) - for uid in ac_uid: - await get_or_create_account(uid) - await get_or_create_account(uid, cu_id) - await batch_add_balance( - [(uid, 1) for uid in ac_uid], - ) - await batch_add_balance( - [(uid, 1) for uid in ac_uid], - currency_id=cu_id, - ) - await batch_del_balance( - [(uid, 1) for uid in ac_uid], - ) - await batch_del_balance( - [(uid, 1) for uid in ac_uid], - currency_id=cu_id, - ) - await list_accounts() - assert all([await del_account(uid) for uid in ac_uid]) - await list_currencies() diff --git a/tests/value_tests/create_currency.py b/tests/value_tests/create_currency.py deleted file mode 100644 index 8adfd9c..0000000 --- a/tests/value_tests/create_currency.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest -from nonebug import App # type: ignore - - -@pytest.mark.asyncio -async def test_new_currency(app: App): - from nonebot_plugin_value.api.api_balance import ( - add_balance, - del_account, - del_balance, - get_or_create_account, - ) - from nonebot_plugin_value.api.api_currency import ( - CurrencyData, - get_currency, - get_or_create_currency, - remove_currency, - ) - from nonebot_plugin_value.uuid_lib import to_uuid - - c_id = "114514" - u_id = to_uuid("114514") - currency = CurrencyData(id=c_id, display_name="test", symbol="t") - await get_or_create_currency(currency) - await get_or_create_account(u_id, c_id) - await add_balance(u_id, 114514, "1919810", c_id) - await del_balance(u_id, 114514, "1919810", c_id) - assert await del_account(u_id, c_id) - await remove_currency(c_id) - assert not await get_currency(c_id) diff --git a/tests/value_tests/default_account.py b/tests/value_tests/default_account.py deleted file mode 100644 index 37ccaba..0000000 --- a/tests/value_tests/default_account.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -from nonebug import App # type: ignore - - -@pytest.mark.asyncio -async def test_balance(app: App): - from nonebot_plugin_value.api.api_balance import ( - add_balance, - del_account, - del_balance, - get_or_create_account, - ) - from nonebot_plugin_value.uuid_lib import to_uuid - - account = to_uuid("Test_Example") - await get_or_create_account(account) - await add_balance( - account, - 100, - "add", - ) - await del_balance( - account, - 100, - "del", - ) - assert await del_account(account) diff --git a/tests/value_tests/depends.py b/tests/value_tests/depends.py deleted file mode 100644 index 459a951..0000000 --- a/tests/value_tests/depends.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest -from nonebug import App # type: ignore - - -def make_event(): - from nonebot.adapters.onebot.v11 import Message, MessageEvent - from nonebot.adapters.onebot.v11.event import Sender - - return MessageEvent( - time=0, - self_id=0, - user_id=123456789, - post_type="message", - message=Message(""), - sub_type="friend", - message_type="private", - message_id=0, - raw_message="", - font=0, - sender=Sender(), - original_message=Message(), - ) - - -@pytest.mark.asyncio -async def test_depends(app: App): - from nonebot_plugin_value.api.api_balance import del_account - from nonebot_plugin_value.api.depends.factory import DependsSwitch - from nonebot_plugin_value.uuid_lib import to_uuid - - uid = to_uuid("123456789") - executor = await (DependsSwitch().account_executor())(make_event()) - await executor.add_balance(100) - assert await executor.get_balance() == 100.0 - await del_account(uid) diff --git a/tests/value_tests/transaction_histories.py b/tests/value_tests/transaction_histories.py deleted file mode 100644 index 5fb8a2f..0000000 --- a/tests/value_tests/transaction_histories.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest -from nonebug import App # type: ignore - - -@pytest.mark.asyncio -async def test_transactions(app: App): - from nonebot_plugin_value.api.api_balance import add_balance, get_or_create_account - from nonebot_plugin_value.api.api_transaction import get_transaction_history - from nonebot_plugin_value.uuid_lib import to_uuid - - account = await get_or_create_account(to_uuid("123")) - for _ in range(20): - await add_balance(to_uuid("123"), 1) - transactions = await get_transaction_history(account.id, 30) - assert len(transactions) >= 20 - assert transactions[0].amount == 1 diff --git a/uv.lock b/uv.lock index 981abaf..9514768 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.10, <4.0.0" [[package]] @@ -258,6 +258,124 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -808,7 +926,7 @@ default = [ [[package]] name = "nonebot-plugin-value" -version = "0.1.6.post1" +version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, @@ -830,6 +948,7 @@ dev = [ { name = "nonebug" }, { name = "pyright" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -854,6 +973,7 @@ dev = [ { name = "nonebug", specifier = ">=0.4.3" }, { name = "pyright", specifier = ">=1.0.0" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.12.0" }, ] @@ -1230,6 +1350,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From f505b6876db7e842f433aa1c4a7bd5d0e7355370 Mon Sep 17 00:00:00 2001 From: John Richard Date: Sat, 4 Apr 2026 11:23:38 +0800 Subject: [PATCH 2/5] Fix: comments --- nonebot_plugin_value/api/api_balance.py | 48 +++++---------- nonebot_plugin_value/api/api_currency.py | 3 - nonebot_plugin_value/services/balance.py | 64 +++++++++++--------- nonebot_plugin_value/services/currency.py | 29 ++++----- nonebot_plugin_value/services/transaction.py | 12 ++-- 5 files changed, 71 insertions(+), 85 deletions(-) diff --git a/nonebot_plugin_value/api/api_balance.py b/nonebot_plugin_value/api/api_balance.py index 3549c67..e1a8f5b 100644 --- a/nonebot_plugin_value/api/api_balance.py +++ b/nonebot_plugin_value/api/api_balance.py @@ -40,8 +40,8 @@ async def set_frozen(account_id: str, currency_id: str, frozen: bool) -> None: """设置账户特定货币冻结状态 Args: - account_id (str): 用户ID - currency_id (str): 货币ID + account_id (str): 账户 ID + currency_id (str): 货币 ID frozen (bool): 是否冻结 """ async with get_session() as session: @@ -141,16 +141,13 @@ async def batch_del_balance( updates: list[tuple[str, float]], currency_id: str | None = None, source: str = "batch_update", -) -> list[UserAccountData] | None: +) -> None: """批量减少账户余额 Args: - updates (list[tuple[str, float]]): 元组列表,包含用户id和金额 - currency_id (str | None, optional): 货币ID. Defaults to None. - source (str, optional): 源说明. Defaults to "batch_update". - - Returns: - None: None + updates (list[tuple[str, float]]): 元组列表,包含用户 id 和金额 + currency_id (str | None, optional): 货币 ID. Defaults to None. + source (str, optional): 源说明。Defaults to "batch_update". """ if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex @@ -165,16 +162,13 @@ async def batch_add_balance( updates: list[tuple[str, float]], currency_id: str | None = None, source: str = "batch_update", -) -> list[UserAccountData] | None: +) -> None: """批量添加账户余额 Args: - updates (list[tuple[str, float]]): 元组列表,包含用户id和金额 - currency_id (str | None, optional): 货币ID. Defaults to None. - source (str, optional): 源说明. Defaults to "batch_update". - - Returns: - None: None + updates (list[tuple[str, float]]): 元组列表,包含用户 id 和金额 + currency_id (str | None, optional): 货币 ID. Defaults to None. + source (str, optional): 源说明。Defaults to "batch_update". """ if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex @@ -194,18 +188,14 @@ async def add_balance( """添加用户余额 Args: - user_id (str): 用户ID + user_id (str): 用户 ID amount (float): 金额 - source (str, optional): 源描述. Defaults to "_transfer". - currency_id (str | None, optional): 货币ID(不填使用默认). Defaults to None. + source (str, optional): 源描述。Defaults to "_transfer". + currency_id (str | None, optional): 货币 ID(不填使用默认). Defaults to None. Raises: RuntimeError: 如果添加失败则抛出异常 - - Returns: - None: None """ - if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex data = await _a_balance(user_id, currency_id, amount, source) @@ -225,16 +215,13 @@ async def del_balance( """减少一个账户的余额 Args: - user_id (str): 用户ID + user_id (str): 用户 ID amount (float): 金额 - source (str, optional): 源说明. Defaults to "_transfer". - currency_id (str | None, optional): 货币ID(不填则使用默认货币). Defaults to Noen. + source (str, optional): 源说明。Defaults to "_transfer". + currency_id (str | None, optional): 货币 ID(不填则使用默认货币). Defaults to None. Raises: RuntimeError: 如果失败则抛出 - - Returns: - UserAccountData: 用户数据 """ if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex @@ -265,9 +252,6 @@ async def transfer_funds( Raises: RuntimeError: 失败则抛出 - - Returns: - None: None """ if currency_id is None: currency_id = DEFAULT_CURRENCY_UUID.hex diff --git a/nonebot_plugin_value/api/api_currency.py b/nonebot_plugin_value/api/api_currency.py index 88fcf33..deaac15 100644 --- a/nonebot_plugin_value/api/api_currency.py +++ b/nonebot_plugin_value/api/api_currency.py @@ -129,9 +129,6 @@ async def create_currency(currency_data: CurrencyData) -> None: Args: currency_data (CurrencyData): 货币信息 - - Returns: - CurrencyData: 货币信息 """ async with get_session() as session: await CacheManager().update_cache( diff --git a/nonebot_plugin_value/services/balance.py b/nonebot_plugin_value/services/balance.py index 9921e06..f495bdc 100644 --- a/nonebot_plugin_value/services/balance.py +++ b/nonebot_plugin_value/services/balance.py @@ -70,8 +70,10 @@ async def del_account( """删除账户 Args: - session (AsyncSession | None, optional): 异步会话. Defaults to None. - user_id (str): 用户ID + account_id (str): 账户 ID + session (AsyncSession | None, optional): 异步会话。Defaults to None. + fail_then_throw (bool, optional): 失败时是否抛出异常。Defaults to False. + currency_id (str | None, optional): 货币 ID. Defaults to None. """ arg_session = session session = session or get_session() @@ -94,7 +96,8 @@ async def list_accounts( """列出所有账户 Args: - session (AsyncSession): 异步会话. Defaults to None. + session (AsyncSession): 异步会话 + currency_id (str | None, optional): 货币 ID. Defaults to None. Returns: Sequence[UserAccount]: 所有账户(指定或所有货币的) @@ -112,9 +115,9 @@ async def get_or_create_account( """获取或创建一个货币的账户 Args: - user_id (str): 用户ID - currency_id (str): 货币ID - session (AsyncSession): 异步会话. Defaults to None. + user_id (str): 用户 ID + currency_id (str): 货币 ID + session (AsyncSession): 异步会话 Returns: UserAccount: 用户数据模型 @@ -132,11 +135,11 @@ async def batch_del_balance( """批量减少账户余额 Args: - updates (list[tuple[str, float]]): 元组列表,包含用户id和金额 - currency_id (str): 货币ID - source (str, optional): 源. Defaults to "batch_update". - session (AsyncSession | None, optional): 异步Session. Defaults to None. - return_all_on_fail (bool, optional): 批量操作失败时是否仍然返回所有结果. Defaults to False. + updates (list[tuple[str, float]]): 元组列表,包含用户 id 和金额 + currency_id (str): 货币 ID + source (str, optional): 源。Defaults to "batch_update". + session (AsyncSession | None, optional): 异步 Session. Defaults to None. + return_all_on_fail (bool, optional): 批量操作失败时是否仍然返回所有结果。Defaults to True. Returns: list[ActionResult]: 操作结果列表 @@ -172,11 +175,11 @@ async def del_balance( """异步减少余额 Args: - user_id (str): 用户ID - currency_id (str): 货币ID + user_id (str): 用户 ID + currency_id (str): 货币 ID amount (float): 金额 - source (str, optional): 来源说明. Defaults to "". - session (AsyncSession | None, optional): 数据库异步会话. Defaults to None. + source (str, optional): 来源说明。Defaults to "". + session (AsyncSession): 数据库异步会话 Returns: ActionResult: 包含是否成功的说明 @@ -249,10 +252,11 @@ async def batch_add_balance( """批量添加余额 Args: - updates (list[tuple[str, float]]): 元组列表 [(用户ID, 金额变化)] - source (str, optional): 来源. Defaults to "batch_update". - session (AsyncSession | None, optional): 会话. Defaults to None. - return_all_on_fail (bool, optional): 返回所有结果即使失败时. Defaults to False. + updates (list[tuple[str, float]]): 元组列表 [(用户 ID, 金额变化)] + currency_id (str): 货币 ID + source (str, optional): 来源。Defaults to "batch_update". + session (AsyncSession | None, optional): 会话。Defaults to None. + return_all_on_fail (bool, optional): 返回所有结果即使失败时。Defaults to False. Returns: list[ActionResult]: 返回的数据(与列表顺序一致,如果任意一个失败则返回空列表) @@ -281,14 +285,14 @@ async def add_balance( """异步增加余额 Args: - user_id (str): 用户ID - currency_id (str): 货币ID + user_id (str): 用户 ID + currency_id (str): 货币 ID amount (float): 金额 - source (str, optional): 来源说明. Defaults to "". - session (AsyncSession | None, optional): 数据库异步会话. Defaults to None. + source (str, optional): 来源说明。Defaults to "". + arg_session (AsyncSession | None, optional): 数据库异步会话。Defaults to None. Returns: - ActionResult: 是否成功("success"),消息说明("message") + ActionResult: 是否成功 ("success"),消息说明 ("message") """ session = arg_session or get_session() async with session if arg_session is None else contextlib.nullcontext(): @@ -366,15 +370,15 @@ async def transfer_funds( """异步转账 Args: - fromuser_id (str): 源用户ID - touser_id (str): 目标用户ID - currency_id (str): 货币ID + fromuser_id (str): 源用户 ID + touser_id (str): 目标用户 ID + currency_id (str): 货币 ID amount (float): 金额 - source (str, optional): 源说明. Defaults to "transfer". - session (AsyncSession | None, optional): 数据库异步Session. Defaults to None. + source (str, optional): 源说明。Defaults to "transfer". + arg_session (AsyncSession | None, optional): 数据库异步 Session. Defaults to None. Returns: - TransferResult: 如果成功则包含"from_balance"(源账户现在的balance),"to_balance"(目标账户现在的balance)字段 + TransferResult: 如果成功则包含"from_balance"(源账户现在的 balance),"to_balance"(目标账户现在的 balance)字段 """ if amount <= 0: diff --git a/nonebot_plugin_value/services/currency.py b/nonebot_plugin_value/services/currency.py index acc566d..8a68a96 100644 --- a/nonebot_plugin_value/services/currency.py +++ b/nonebot_plugin_value/services/currency.py @@ -16,7 +16,7 @@ async def update_currency( Args: currency_data (CurrencyData): 货币元信息 - session (AsyncSession): 异步Session. . + session (AsyncSession): 异步 Session Returns: CurrencyMeta: 货币元信息模型 @@ -48,7 +48,7 @@ async def list_currencies( """获取已存在的货币 Args: - session (AsyncSession | None, optional): 异步Session. Defaults to None. + session (AsyncSession): 异步 Session Returns: Sequence[CurrencyMeta]: 返回货币列表 @@ -63,11 +63,11 @@ async def get_currency( """获取一个货币的元信息 Args: - session (AsyncSession): SQLAlchemy的异步session - currency_id (str): 货币唯一ID + currency_id (str): 货币唯一 ID + session (AsyncSession): SQLAlchemy 的异步 session Returns: - CurrencyMeta | None: 货币元数据(不存在为None) + CurrencyMeta | None: 货币元数据(不存在为 None) """ return await CurrencyRepository(session).get_currency(currency_id) @@ -80,11 +80,11 @@ async def get_currency_by_kwargs( """获取一个货币的元信息 Args: - session (AsyncSession): SQLAlchemy的异步session - kwargs (object): 货币元信息字段 + session (AsyncSession): SQLAlchemy 的异步 session + **kwargs (object): 查询条件(货币属性字段) Returns: - CurrencyMeta | None: 货币元数据(不存在为None) + CurrencyMeta | None: 货币元数据(不存在为 None) """ return await CurrencyRepository(session).get_currency_by_kwargs(**kwargs) @@ -92,12 +92,12 @@ async def get_currency_by_kwargs( async def create_currency( currency_data: CurrencyData, session: AsyncSession | None = None -) -> None: +) -> CurrencyMeta: """创建货币 Args: - session (AsyncSession): SQLAlchemy的异步session currency_data (CurrencyData): 货币数据 + session (AsyncSession | None, optional): SQLAlchemy 的异步 session. Defaults to None. Returns: CurrencyMeta: 创建的货币元数据 @@ -105,9 +105,10 @@ async def create_currency( arg_session = session session = session or get_session() async with session if arg_session is None else contextlib.nullcontext(): - await CurrencyRepository(session).createcurrency(currency_data) + result = await CurrencyRepository(session).createcurrency(currency_data) if not arg_session: await session.commit() + return result async def get_or_create_currency( @@ -117,11 +118,11 @@ async def get_or_create_currency( """获取或创建新货币(如果存在就获取) Args: - session (AsyncSession): SQLAlchemy的异步session currency_data (CurrencyData): 货币元信息 + session (AsyncSession): SQLAlchemy 的异步 session Returns: - tuple[CurrencyMeta, bool] 元数据和是否创建 + tuple[CurrencyMeta, bool]: 元数据和是否创建 """ repo = CurrencyRepository(session) @@ -132,7 +133,7 @@ async def get_default_currency(session: AsyncSession) -> CurrencyMeta: """获取默认货币 Args: - session (AsyncSession | None, optional): 异步会话. Defaults to None. + session (AsyncSession): 异步会话 Returns: CurrencyMeta: 货币元数据 diff --git a/nonebot_plugin_value/services/transaction.py b/nonebot_plugin_value/services/transaction.py index 32cfeee..013a7e9 100644 --- a/nonebot_plugin_value/services/transaction.py +++ b/nonebot_plugin_value/services/transaction.py @@ -16,11 +16,11 @@ async def get_transaction_history_by_time_range( """通过时间范围获取账户交易历史 Args: - account_id (str): 用户ID + account_id (str): 账户 ID start_time (datetime): 起始时间 end_time (datetime): 结束时间 - limit (int, optional): 条数限制. Defaults to 100. - session (AsyncSession | None, optional): 会话. Defaults to None. + session (AsyncSession): 会话 + limit (int, optional): 条数限制。Defaults to 100. Returns: Sequence[Transaction]: 记录 @@ -41,9 +41,9 @@ async def get_transaction_history( """获取一个用户的交易记录 Args: - session (AsyncSession | None, optional): 异步数据库会话 - account_id (str): 用户UUID(应自行处理) - limit (int, optional): 数据条数. Defaults to 100. + account_id (str): 账户 UUID(应自行处理) + session (AsyncSession): 异步数据库会话 + limit (int, optional): 数据条数。Defaults to 100. Returns: Sequence[Transaction]: 记录列表 From d3ec73d85c987ace113f2c6442791cfac5415db0 Mon Sep 17 00:00:00 2001 From: John Richard Date: Sat, 4 Apr 2026 11:25:38 +0800 Subject: [PATCH 3/5] Remove: JUnit --- .github/workflows/PR.yml | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml index 8ea66e2..8c93f88 100644 --- a/.github/workflows/PR.yml +++ b/.github/workflows/PR.yml @@ -45,18 +45,8 @@ jobs: - name: Prepare database run: uv run nb orm upgrade - - name: Run Unit Tests with JUnit XML output - run: uv run pytest tests/* --cov=nonebot_plugin_value --cov-report=term-missing --cov-report=xml --junitxml=test-results.xml -v + - name: Run Unit Tests + run: uv run pytest tests/* --cov=nonebot_plugin_value --cov-report=term-missing --cov-report=xml -v - - name: Publish Test Report - uses: dorny/test-reporter@v1 - if: success() || failure() - with: - name: Python Unit Tests - path: test-results.xml - reporter: java-junit - fail-on-error: false - - - name: Build package run: uv build # 生成构建产物到dist目录 From a7a027e429cdff258c4e7f61aac7798cbbe7a612 Mon Sep 17 00:00:00 2001 From: John Richard Date: Sat, 4 Apr 2026 11:43:12 +0800 Subject: [PATCH 4/5] Resolve: problems --- nonebot_plugin_value/services/balance.py | 35 ++++++++++++++++-------- tests/test_api_coverage.py | 19 +++++++++++-- tests/test_hooks.py | 4 +-- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/nonebot_plugin_value/services/balance.py b/nonebot_plugin_value/services/balance.py index f495bdc..d54f6a8 100644 --- a/nonebot_plugin_value/services/balance.py +++ b/nonebot_plugin_value/services/balance.py @@ -46,19 +46,23 @@ async def set_frozen_all( frozen: bool, session: AsyncSession | None = None, ): - """冻结这个账户ID下的所有货币储备 + """冻结这个账户 ID 下的所有货币储备 Args: - account_id (str): 账户ID + account_id (str): 账户 ID frozen (bool): 是否冻结 - session (AsyncSession | None, optional): 异步Session. Defaults to None. + session (AsyncSession | None, optional): 异步 Session. Defaults to None. """ arg_session = session session = session or get_session() - async with session if arg_session is None else contextlib.nullcontext(): - await AccountRepository(session).set_frozen_all(account_id, frozen) - if not arg_session: - await session.commit() + + async with session.begin() if not arg_session else contextlib.nullcontext(): + try: + await AccountRepository(session).set_frozen_all(account_id, frozen) + except Exception: + if arg_session: + await session.rollback() + raise async def del_account( @@ -142,24 +146,31 @@ async def batch_del_balance( return_all_on_fail (bool, optional): 批量操作失败时是否仍然返回所有结果。Defaults to True. Returns: - list[ActionResult]: 操作结果列表 + list[ActionResult]: 操作结果列表(与输入顺序一致) """ arg_session = session session = session or get_session() - result_list: list[ActionResult] = [] async def task_inner(uid: str, amount: float): - nonlocal result_list data: ActionResult = await del_balance( uid, currency_id, amount, source=source, session=session ) - result_list.append(data) + return data async with session.begin() if arg_session is None else contextlib.nullcontext(): - await asyncio.gather( + results: list[ActionResult | BaseException] = await asyncio.gather( *[task_inner(uid, amount) for uid, amount in updates], return_exceptions=True, ) + result_list: list[ActionResult] = [] + for result in results: + if isinstance(result, BaseException): + result_list.append( + ActionResult(success=False, message=f"操作失败:{result!s}") + ) + else: + result_list.append(result) + if not all(r.success for r in result_list): return [] if not return_all_on_fail else result_list return result_list diff --git a/tests/test_api_coverage.py b/tests/test_api_coverage.py index 58baae9..d2e65c8 100644 --- a/tests/test_api_coverage.py +++ b/tests/test_api_coverage.py @@ -149,7 +149,22 @@ async def test_transaction_api(app: App): ) assert len(empty_transactions) == 0, "未来时间范围应该没有交易记录" - # 测试删除交易记录(可选,如果需要测试的话) + # 测试删除交易记录 if len(transactions_all) > 0: first_tx_id = transactions_all[0].id - await remove_transaction(first_tx_id) + + # 验证删除操作的返回值 + delete_result = await remove_transaction(first_tx_id) + assert delete_result is True, "删除交易记录应该返回 True" + + # 验证删除效果:重新获取交易记录,确认已删除 + transactions_after_delete = await get_transaction_history( + test_user_id, limit=100 + ) + deleted_tx_ids = [tx.id for tx in transactions_after_delete] + assert first_tx_id not in deleted_tx_ids, "删除后的交易 ID 不应该出现在列表中" + + # 验证数量减少 + assert len(transactions_after_delete) == len(transactions_all) - 1, ( + "删除后交易记录数量应该减少 1" + ) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 1d0d9f2..060ffc9 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -140,6 +140,6 @@ async def error_hook(context: TransactionContext): setattr(manager, "_HooksManager__hooks", {}) manager.register(HooksType.pre(), error_hook) - # 应该记录错误但不中断执行 - await manager.run_hooks(HooksType.pre(), pre_context) + with pytest.raises(ValueError, match="Test error in hook"): + await manager.run_hooks(HooksType.pre(), pre_context) assert error_hook_called, "错误的钩子也应该被调用" From 8e7755f35929d1afa1e76e2654ba6b9c3c2ea9f6 Mon Sep 17 00:00:00 2001 From: John Richard Date: Sat, 4 Apr 2026 11:45:14 +0800 Subject: [PATCH 5/5] Fix: tests --- tests/test_hooks.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 060ffc9..3da14c2 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -135,11 +135,10 @@ async def update_hook(context: TransactionContext): async def error_hook(context: TransactionContext): nonlocal error_hook_called error_hook_called = True - raise ValueError("Test error in hook") + raise ValueError("Test error in hook") # 这个会被Inner捕捉 setattr(manager, "_HooksManager__hooks", {}) manager.register(HooksType.pre(), error_hook) - with pytest.raises(ValueError, match="Test error in hook"): - await manager.run_hooks(HooksType.pre(), pre_context) + await manager.run_hooks(HooksType.pre(), pre_context) assert error_hook_called, "错误的钩子也应该被调用"