feat(testing): 契约套件搬进 polyloop.testing 随包发布,五套全部接上实现
tests/ 不进 wheel,所以那套被 CLAUDE.md §0 称作「任何新适配器的准入标准」的用例,第一个 下游根本拿不到。**接法同时换掉**:pytest 的 conftest 只沿被收集文件的目录链查找,装在 site-packages 里的测试模块看不见下游的 conftest,原来那个「在自己的 conftest 里覆盖同名 fixture」的接法在发布之后走不通。改成继承契约基类,下游的子类定义在自己的目录链上。 **搬的过程中发现这套准入标准从来没被执行过。** test_model_client.py 有四条用例调用 records.model_call(...),而工厂里根本没有这个方法——它没炸是因为那个 fixture 默认 skip。 五个接缝里只有存储那套被真跑过(15 条跳过里有 15 条是这四套)。 所以这个提交的另一半是让它真的跑起来。存储接两个实现(一份契约同时验多个实现,正是换接法 换来的);动作执行接注册表分发器,外加一个有真实等待点的替身,否则那条取消用例的断言半边 永远走不到;模型调用接网关适配器,落在 integration,它连的是真网关;决策解释与事件出口各 接一个测试替身——替身住在 tests/ 里不进 wheel,下游拿不到,所以不违反「库不带默认实现」, 判据是下游拿不拿得到。 **一并清掉两类坏用例。** 五条函数体只有 docstring、一个断言都没有却报 PASSED 的假绿——一个 准入标准里出现假绿比出现跳过糟得多,下游看到全绿会以为验过了。以及一条端口从没承诺过的 长度断言(len(history_text) <= len(reply.content)):压测的 AppWorld 场景为了迁就它,刻意 不补被复刻的实现真的会补的三个反引号,注释里写着「补一个字符就违约」。七条「这一层验不了」 统一成无条件 skip,理由字符串写全「承诺是什么/为什么验不了/你该在哪儿自己验」。 **发一个 pytest11 entry point,只为换回断言重写。** 契约模块不在下游的 python_files 里, 默认不被重写,于是一条契约失败时下游看到的是光秃秃的 AssertionError。不做的话没有任何东西 会报错,纯静默退化。实测过:editable 安装下 entry point 注册了但重写不生效(RECORD 里没有 包文件),要装真 wheel 才验得出来。
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""把动作执行契约接到一个有挂起点的测试替身上。
|
||||
|
||||
同一套用例在两种形态上各跑一遍:`test_registry_executor.py` 接的分发器是纯计算的,取消那条
|
||||
用例在它身上永远走跳过分支——取消发出去时它已经跑完,没有机会吞掉取消。那条用例的断言半边
|
||||
因此在本仓库一次都没被执行过,而契约套件的目标是每一条用例都至少被真的执行一次
|
||||
(`research-wiki/design/0014-contract-suite-distribution.md` 决策六)。这个替身补的就是「有挂起
|
||||
点」那一档。
|
||||
|
||||
**不是给分发器塞一句人为的等待。** 那样换来的通过验的是那句等待,不是分发器有没有吞掉取消
|
||||
(`test_registry_executor.py` 里那个工具的 docstring 说的就是这件事)。补一个另外的实现,验的
|
||||
是真有等待点时取消穿不穿得过去。
|
||||
|
||||
**这个替身住在 `tests/` 里,不进 wheel,任何下游都拿不到它。** 「库不带默认实现」那条禁的是
|
||||
`src/` 下出现一个能用的实现——下游装了包就拿得到,就会有人直接用,于是动作语言被库替它定了。
|
||||
判据是下游拿不拿得到,不是代码库里有没有一个能跑的实现(同上,决策六)。
|
||||
|
||||
`contract` 这个标记打在本文件上,不打在套件里,理由见 `test_run_stores.py`。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from polyloop.ports import Action
|
||||
from polyloop.testing import ActionExecutorContract, RecordFactory
|
||||
from polyloop.types import ActionOutcome, ActionStatus
|
||||
|
||||
pytestmark = pytest.mark.contract
|
||||
|
||||
#: 这个替身认得的全部动作语言:正文以它开头的那个动作,跑完之后自己报错。
|
||||
_FAILS_PREFIX = "FAIL "
|
||||
|
||||
#: 挂起窗口的长度。**不能用 `asyncio.sleep(0)`**:那只是让出一次,取消赶不赶得上就取决于事件
|
||||
#: 循环就绪队列里两个回调的先后,而那个顺序不是承诺。给一个真的定时器,套件让出一次之后这个
|
||||
#: 替身一定还没跑完,`cancel()` 一定返回 `True`,取消那条用例才稳定地走到断言那一半。
|
||||
_PAUSE_SECONDS = 0.001
|
||||
|
||||
|
||||
class _SuspendingActionExecutor:
|
||||
"""每个动作都先真的挂起一小段,再返回一个「已执行」的结果。
|
||||
|
||||
那次挂起模拟的是真实实现里的等待点——网络往返、子进程、容器会话。**它不捕获任何异常**,
|
||||
所以挂起期间收到的 `CancelledError` 原样穿出去,这正是契约要断言的行为
|
||||
(`CLAUDE.md` §1.6)。没有 in-flight 资源要放,所以也没有 `finally`。
|
||||
|
||||
它满足 `polyloop.ports.ActionExecutor`,但不显式继承那个 Protocol:结构化子类型不需要继承。
|
||||
"""
|
||||
|
||||
async def execute(self, action: Action) -> ActionOutcome:
|
||||
await asyncio.sleep(_PAUSE_SECONDS)
|
||||
if action.text.startswith(_FAILS_PREFIX):
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.EXECUTED,
|
||||
observation=f"动作自己报错了:{action.text[len(_FAILS_PREFIX) :]}",
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.EXECUTED,
|
||||
observation=f"动作的输出:{action.text}",
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
def parameters(self) -> Mapping[str, str]:
|
||||
return {"kind": "suspending", "pause_seconds": str(_PAUSE_SECONDS)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class _ActionSamples:
|
||||
executes_cleanly: Action
|
||||
executes_but_errors: Action
|
||||
|
||||
|
||||
class TestSuspendingActionExecutor(ActionExecutorContract):
|
||||
@pytest.fixture
|
||||
def action_executor(self) -> _SuspendingActionExecutor:
|
||||
return _SuspendingActionExecutor()
|
||||
|
||||
@pytest.fixture
|
||||
def action_samples(self, records: RecordFactory) -> _ActionSamples:
|
||||
"""两个样本的状态都是「已执行」,动作本身报错的那个也是。
|
||||
|
||||
这个替身的动作语言里没有「没进执行」和「环境坏了」这两档,报错的动作照样跑完了,只是
|
||||
观察里多一句错误说明——套件对样本的要求就是这个(`action_samples` 的退化情况那一段)。
|
||||
"""
|
||||
return _ActionSamples(
|
||||
executes_cleanly=records.action(text="做点事"),
|
||||
executes_but_errors=records.action(text=f"{_FAILS_PREFIX}除以零"),
|
||||
)
|
||||
Reference in New Issue
Block a user