Skip to content

Commit 55864d8

Browse files
committed
test(support): make tests self-contained; rebuild integration off examples
tests/support 承载断言层+框架+逐字场景+桩+finish_session×2+finish_dream;test_examples→test_scenarios_offline 改测 tests/support(safe_error 两分支保留);新建 tests/integration;testpaths/marker/addopts。examples 未改。 Task: 1789906941
1 parent f5b6946 commit 55864d8

17 files changed

Lines changed: 1489 additions & 511 deletions

‎Makefile‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ build:
2424
$(PYTHON) -m build
2525

2626
test-live:
27-
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest examples/forward -m live -v
27+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration/test_forward.py -m integration -v
2828

2929
test-live-managed:
30-
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest examples/managed -m live -v
30+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration/test_managed.py -m integration -v
3131

3232
test-live-all:
33-
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest examples -m live -v
33+
QODER_RUN_LIVE=1 QODER_LIVE_ENV_FILE="$(LIVE_ENV_FILE)" $(PYTHON) -m pytest tests/integration -m integration -v

‎pyproject.toml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ namespaces = false
4646
"*" = ["py.typed"]
4747

4848
[tool.pytest.ini_options]
49-
testpaths = ["tests", "examples"]
49+
testpaths = ["tests"]
5050
asyncio_mode = "auto"
5151
asyncio_default_fixture_loop_scope = "function"
52-
markers = ["live: opt-in tests against a real Qoder account"]
52+
markers = ["integration: account-backed integration tests"]
53+
addopts = '-m "not integration" --strict-markers'
5354

5455
[tool.ruff]
5556
target-version = "py310"

‎tests/integration/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""账号级集成测试(live):默认被 -m "not integration" deselect。
2+
3+
client/凭据仅在 conftest 的 live_example fixture 内构造,模块 import 期无网络。
4+
"""

‎tests/integration/conftest.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os
2+
3+
import pytest
4+
5+
from qca import Forward, Managed
6+
from tests.support.harness import Config, Run
7+
8+
9+
@pytest.fixture
10+
def live_example(request):
11+
if os.environ.get("QODER_RUN_LIVE") != "1":
12+
pytest.skip("Set QODER_RUN_LIVE=1 to run account-backed examples")
13+
mode = request.param
14+
config = Config.load(
15+
mode,
16+
env_file=os.environ.get("QODER_LIVE_ENV_FILE", ".env.live"),
17+
timeout=float(os.environ.get("QODER_LIVE_TIMEOUT", "300")),
18+
)
19+
context = Run(config)
20+
client_type = Forward if mode == "forward" else Managed
21+
with client_type(**config.client_options()) as client:
22+
try:
23+
yield client, context
24+
finally:
25+
context.cleanup()

‎tests/integration/test_forward.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
from tests.support.scenarios.forward import SCENARIOS
4+
5+
pytestmark = pytest.mark.integration
6+
7+
8+
@pytest.mark.parametrize("live_example", ["forward"], indirect=True)
9+
@pytest.mark.parametrize("scenario", SCENARIOS.values(), ids=SCENARIOS)
10+
def test_forward_example(live_example, scenario):
11+
client, context = live_example
12+
scenario(client, context)

‎tests/integration/test_managed.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
from tests.support.scenarios.managed import SCENARIOS
4+
5+
pytestmark = pytest.mark.integration
6+
7+
8+
@pytest.mark.parametrize("live_example", ["managed"], indirect=True)
9+
@pytest.mark.parametrize("scenario", SCENARIOS.values(), ids=SCENARIOS)
10+
def test_managed_example(live_example, scenario):
11+
client, context = live_example
12+
scenario(client, context)

‎tests/support/__init__.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""tests 自足支持层:断言层 + 运行时基元副本 + verbatim 场景 + HTTP 桩 + 清理。
2+
3+
tests 不依赖 examples、examples 亦不依赖 tests(双向断开)。场景 SCENARIOS 请从
4+
tests.support.scenarios.forward / tests.support.scenarios.managed 取用。
5+
"""
6+
7+
from tests.support.assertions import TurnResult, turn, wait_reply
8+
from tests.support.cleanup import finish_dream, finish_session_forward, finish_session_managed
9+
from tests.support.harness import Config, Run, choose_model, marker, name, read_env, safe_error
10+
from tests.support.memory import ProjectMemory
11+
from tests.support.stub import ExampleService
12+
13+
__all__ = [
14+
"Config",
15+
"ExampleService",
16+
"ProjectMemory",
17+
"Run",
18+
"TurnResult",
19+
"choose_model",
20+
"finish_dream",
21+
"finish_session_forward",
22+
"finish_session_managed",
23+
"marker",
24+
"name",
25+
"read_env",
26+
"safe_error",
27+
"turn",
28+
"wait_reply",
29+
]

‎tests/support/assertions.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""测试专用的断言层(TurnResult/wait_reply/turn)。
2+
3+
这是从 examples/common/live.py 净移出的断言层——examples 侧不再保留。
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
from typing import Any
10+
11+
from tests.support.harness import Run, name
12+
13+
14+
@dataclass
15+
class TurnResult:
16+
text: str = ""
17+
last_id: str = ""
18+
tool_used: bool = False
19+
complete: bool = False
20+
21+
def observe(self, event: Any) -> None:
22+
if hasattr(event, "to_dict"):
23+
event = event.to_dict(mode="json")
24+
kind = event.get("type")
25+
if event.get("id"):
26+
self.last_id = event["id"]
27+
if kind in ("session.error", "session.status_terminated"):
28+
raise AssertionError(f"Execution failed: {kind}, event_id={self.last_id}")
29+
if kind in ("agent.tool_use", "agent.mcp_tool_use"):
30+
self.tool_used = True
31+
elif kind == "agent.message":
32+
# Only the latest completed assistant message can satisfy assertions.
33+
self.text = "\n".join(
34+
block.get("text", "") for block in event.get("content", []) if block.get("type") == "text"
35+
)
36+
elif kind == "session.status_idle":
37+
reason = event.get("stop_reason")
38+
reason = reason.get("type") if isinstance(reason, dict) else reason
39+
if reason not in (None, "", "end_turn", "stop_sequence"):
40+
raise AssertionError(f"Execution stopped early: {reason}, event_id={self.last_id}")
41+
self.complete = bool(self.text)
42+
43+
def verify(self, expected: list[str], require_tool: bool = False) -> None:
44+
if not self.complete:
45+
raise AssertionError(f"No idle state after assistant output; last_event_id={self.last_id}")
46+
if not all(value in self.text for value in expected):
47+
raise AssertionError(f"Assistant output is missing expected values; last_event_id={self.last_id}")
48+
if require_tool and not self.tool_used:
49+
raise AssertionError(f"No actual tool execution; last_event_id={self.last_id}")
50+
51+
52+
def wait_reply(events: Any, run: Run, session_id: str, after: str = "") -> TurnResult:
53+
result = TurnResult(last_id=after)
54+
while not result.complete:
55+
run.remaining()
56+
page = events.list(
57+
session_id,
58+
order="asc",
59+
limit=100,
60+
extra_query={"after_id": result.last_id or None, "include_tool_calls": True},
61+
timeout=min(run.remaining(), 30),
62+
)
63+
for index, event in enumerate(page):
64+
if index >= 2000:
65+
raise AssertionError("Event polling exceeded 2000 events")
66+
result.observe(event)
67+
if result.complete:
68+
break
69+
if not result.complete:
70+
run.pause()
71+
run.output("assistant", result.text)
72+
return result
73+
74+
75+
def turn(
76+
events: Any,
77+
run: Run,
78+
session_id: str,
79+
prompt: str,
80+
expected: list[str],
81+
*,
82+
require_tool: bool = False,
83+
) -> TurnResult:
84+
run.output("user", prompt)
85+
result = events.send(
86+
session_id,
87+
events=[{"type": "user.message", "content": [{"type": "text", "text": prompt}]}],
88+
extra_headers={"Idempotency-Key": name("event")},
89+
)
90+
if len(result.data) != 1 or not result.data[0].id:
91+
raise AssertionError("Send must return exactly one user event ID")
92+
reply = wait_reply(events, run, session_id, result.data[0].id)
93+
reply.verify(expected, require_tool)
94+
return reply

‎tests/support/cleanup.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""场景复用的会话与 dream 清理。
2+
3+
forward 与 managed 的 finish_session 是两份不同实现(forward: cancel+archive;
4+
managed: user.interrupt+delete),随各自 SCENARIOS 分别迁自 examples/{forward,managed}/_cleanup.py;
5+
finish_dream 迁自 examples/managed/dream.py。场景侧以 `as finish_session` 别名保持调用点逐字不变。
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from qca import Forward, Managed
11+
from tests.support.harness import Run
12+
13+
14+
def finish_session_forward(client: Forward, context: Run, session_id: str) -> None:
15+
session = client.sessions.retrieve(session_id)
16+
if session.status not in ("idle", "terminated"):
17+
client.sessions.cancel(session_id)
18+
while client.sessions.retrieve(session_id).status not in ("idle", "terminated"):
19+
context.pause()
20+
client.sessions.archive(session_id)
21+
22+
23+
def finish_session_managed(client: Managed, context: Run, session_id: str) -> None:
24+
session = client.sessions.retrieve(session_id)
25+
if session.status not in ("idle", "terminated"):
26+
client.sessions.events.send(session_id, events=[{"type": "user.interrupt"}])
27+
while client.sessions.retrieve(session_id).status not in ("idle", "terminated"):
28+
context.pause()
29+
client.sessions.delete(session_id)
30+
31+
32+
def finish_dream(client: Managed, context: Run, dream_id: str, input_store_id: str) -> None:
33+
dream = client.dreams.retrieve(dream_id)
34+
if dream.status in ("pending", "running"):
35+
client.dreams.cancel(dream_id)
36+
while dream.status in ("pending", "running"):
37+
context.pause()
38+
dream = client.dreams.retrieve(dream_id)
39+
errors = []
40+
actions = []
41+
if dream.session_id:
42+
actions.append(lambda: finish_session_managed(client, context, dream.session_id))
43+
seen = {input_store_id}
44+
for output in dream.outputs or []:
45+
if output.memory_store_id and output.memory_store_id not in seen:
46+
seen.add(output.memory_store_id)
47+
actions.append(lambda store_id=output.memory_store_id: client.memory_stores.delete(store_id))
48+
actions.append(lambda: client.dreams.archive(dream_id))
49+
for action in actions:
50+
try:
51+
action()
52+
except Exception as error:
53+
errors.append(error)
54+
if errors:
55+
raise RuntimeError(f"Dream cleanup had {len(errors)} failures") from errors[0]

0 commit comments

Comments
 (0)