Files
projectAIpopular/tests/test_workspace.py
T

232 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T4 交流文本 Workspace 单测(封闭,纯逻辑)。"""
import json
import pytest
from router_system.workspace import (
LIMITS,
STATUS_FLOW,
Workspace,
build_anchor,
estimate_tokens,
parse_anchor,
validate,
)
ALLOWED_BRIEF = {
"goal": "用 Python 实现快速排序并解释复杂度",
"constraints": ["必须用标准库", "时间复杂度 O(n log n)"],
"tags": ["code"],
"acceptance": [{"id": "a1", "check": "排序结果正确", "machine_checkable": True}],
"plan": [
{"id": "s1", "task": "实现快排主函数", "deps": [], "done_criteria": "可运行"},
{"id": "s2", "task": "补充单元测试", "deps": ["s1"], "done_criteria": "3/3 通过"},
],
}
def _ws(**kw):
return Workspace.new(request_id=kw.get("request_id", "abc123def456"),
query=kw.get("query", "写个快排"),
api_token_cap=kw.get("api_token_cap", 8000),
rounds_cap=kw.get("rounds_cap", 6))
# ---------- 结构/校验 ----------
def test_new_has_valid_defaults():
ws = _ws()
assert ws.status == "draft"
assert validate(ws.data) == []
b = ws.budget()
assert b["api_token_cap"] == 8000 and b["rounds_cap"] == 6
def test_brief_write_once():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
assert ws.status == "in_progress"
with pytest.raises(ValueError):
ws.apply_brief(ALLOWED_BRIEF) # 二次写入被拒
def test_apply_brief_missing_field_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF)
del bad["plan"]
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_invalid_tag_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF, tags=["hacker"])
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_overlong_goal_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF, goal="长" * (LIMITS["goal"] + 1))
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_too_many_plan_steps_rejected():
ws = _ws()
plan = [{"id": f"s{i}", "task": "t", "deps": [], "done_criteria": "c"}
for i in range(LIMITS["plan_steps"] + 1)]
bad = dict(ALLOWED_BRIEF, plan=plan)
with pytest.raises(ValueError):
ws.apply_brief(bad)
# ---------- 状态机 ----------
def test_status_transitions():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.transition("reviewing")
ws.transition("done")
assert ws.status == "done"
with pytest.raises(ValueError):
ws.transition("in_progress") # done 之后不允许再回
def test_illegal_transition():
ws = _ws()
with pytest.raises(ValueError):
ws.transition("done") # draft 不能直接到 done
# ---------- 写入 + 预算 ----------
def test_progress_and_issues_and_decisions():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "实现完成", artifact="a://s1_main.py")
iid = ws.add_issue("s2", "a://s2_tests.py#L12-18",
"测试失败", "3/3 通过", "修了两次", "请裁决")
assert iid == "i1"
ws.add_decision(iid, "改用排序后断言", [{"id": "s2", "task": "修测试"}])
assert len(ws["progress"]) == 1
assert len(ws["issues"]) == 1
assert len(ws["decisions"]) == 1
def test_budget_accumulation_and_exhaustion():
ws = _ws(api_token_cap=10)
ws.apply_brief(ALLOWED_BRIEF)
ws.add_budget(input_tokens=6, output_tokens=2)
assert ws.exhausted() is False
ws.add_budget(input_tokens=3) # 累计 11 > 10
assert ws.exhausted() is True
def test_round_exhaustion():
ws = _ws(rounds_cap=2)
ws.apply_brief(ALLOWED_BRIEF)
ws.mark_round()
ws.mark_round()
assert ws.exhausted() is True
def test_issue_text_overlong_rejected():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
with pytest.raises(ValueError):
ws.add_issue("s1", "a://f.py", "观" * (LIMITS["issue_text"] + 1), "e", "t", "q")
# ---------- rollup ----------
def test_rollup_folds_done_progress_and_resolved_issues():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "快排实现完成", "a://s1_main.py")
ws.add_progress("s2", "failed", "测试未过")
iid = ws.add_issue("s2", "a://s2_tests.py#L1", "失败", "通过", "试过", "问")
ws.add_decision(iid, "已修复")
n = ws.rollup()
assert n == 1 # 折叠 1 条 done progress
assert "s1" in "".join(ws["archive"])
# s2 failed 保留;已 resolve 的 issue 从 issues 移除
steps = [p["step"] for p in ws["progress"]]
assert steps == ["s2"]
assert len(ws["issues"]) == 0
def test_rollup_keeps_unresolved_issue():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "ok", "a://s1.py")
ws.add_issue("s2", "a://s2.py#L1", "obs", "exp", "try", "ask") # 无 decision
ws.rollup()
assert len(ws["issues"]) == 1
# ---------- 渲染 ----------
def test_render_for_architect_within_budget():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
for i in range(6):
ws.add_progress(f"s{i}", "done", f"步骤{i}完成" * 3)
ws.add_issue(f"s{i}", f"a://f{i}.py#L1", "obs", "exp", "try", "ask" * 10)
ws.add_decision(f"i{i+1}", "裁决" * 20)
out = ws.render_for_architect()
assert estimate_tokens(out) <= 1200
assert "== meta ==" in out
def test_render_for_worker_has_step_and_criteria():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
out = ws.render_for_worker("s2", artifact_text="def test(): pass")
assert "当前步" in out
assert "s2" in out
assert "def test(): pass" in out
assert "验收标准" in out and "a1" in out
def test_render_for_worker_marks_current_step():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
out = ws.render_for_worker("s1")
assert "<-- 当前步" in out
# ---------- 锚点 ----------
def test_anchor_build_and_parse():
a = build_anchor("s1_main.py", 12, 18)
assert a == "a://s1_main.py#L12-18"
p = parse_anchor(a)
assert p == {"file": "s1_main.py", "start": 12, "end": 18}
# 无行号
assert parse_anchor("a://f.py") == {"file": "f.py", "start": 1, "end": 1}
assert parse_anchor("not-an-anchor") is None
# ---------- 持久化 ----------
def test_save_load_roundtrip(tmp_path):
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "ok")
path = tmp_path / "ws.json"
ws.save(path)
loaded = Workspace.load(path)
assert loaded.to_dict() == ws.to_dict()
assert loaded.request_id == ws.request_id
def test_json_serializable(tmp_path):
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
# 应能被 json 直接序列化(中文 ensure_ascii=False
s = json.dumps(ws.to_dict(), ensure_ascii=False)
assert json.loads(s)["request_id"] == ws.request_id
def test_validate_rejects_non_object():
assert validate("nope") != []
def test_status_flow_enumeration():
assert set(STATUS_FLOW.keys()) == {"draft", "in_progress", "reviewing", "escalated", "done", "failed"}