feat(v2): T06-b 方案装配与依赖预检(可定位的 E-19 文案)
T06 第二块。核对后确认 component_registry.freeze() 已实现大部分 AC-28: 按 release 指针解析依赖、原生方法文本装配时固化、循环依赖与输出冲突检查、 数据域一致性("演示与正式组件不能混用")、content_hash。因此本批**不重复实现冻结**, 只补真正的缺口。 新增 contracts:JudgmentBinding、ReviewPlan(含维度、关键核查项、专项绑定)。 新增 review_plan.check_dependencies() / assemble(): - **缺口**:未发布/已撤回的依赖原先抛裸 KeyError,被 translate() 映射成 404 "对象不存在",用户无法定位是哪一项。现改为逐个检查并**指明依赖名与原因** ("尚未发布或已撤回"/"版本状态为 X"/"发布指针指向的版本不存在") - 只用 release 指针判定可用性——草稿与已撤回天然没有指针,这正是 BR-27 "草稿或已撤回组件不得进入新运行"的机械表达 - 装配时接入 T06-a 的维度解析与呈现判定,并做不丢失断言 测试:test_review_plan.py 增至 25 项(新增 7 项装配)。其中版本换版后 content_hash 必须变化,用于证明版本确实被冻结进结果而不是每次重解析。 全量 340 项 OK(333 → 340);verify.py 10/10 PASS。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,12 @@
|
||||
- 呈现判定覆盖 0 / 2 / 6 / 9 维与异刻度,且**不丢失任何维度**
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from services import assistant_store as store
|
||||
from services import component_registry as registry
|
||||
from services import review_plan as plan
|
||||
|
||||
SCALE = "evidence-stage"
|
||||
@@ -145,5 +149,107 @@ class PresentationTests(unittest.TestCase):
|
||||
self.assertEqual(self.check(dimensions)["layout"], "faceted")
|
||||
|
||||
|
||||
|
||||
|
||||
class PlanAssemblyTests(unittest.TestCase):
|
||||
"""AC-28 / E-19:只有全部依赖有效且同域才装配;否则拒绝并指明是哪一项。"""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db_path = Path(self._tmp.name) / "plan.sqlite3"
|
||||
store.initialize(self.db_path)
|
||||
with store.transaction(self.db_path) as db:
|
||||
registry.seed(db) # 内置演示组件,含 corporate.default 方案
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def assemble(self, plan_id="corporate.default"):
|
||||
with store.transaction(self.db_path) as db:
|
||||
return plan.assemble(db, plan_id)
|
||||
|
||||
def test_published_plan_assembles_and_freezes(self):
|
||||
frozen = self.assemble()
|
||||
self.assertEqual(frozen["plan"]["id"], "corporate.default")
|
||||
self.assertEqual({s["kind"] for s in frozen["skills"]}, {"skill"})
|
||||
self.assertEqual({r["kind"] for r in frozen["rules"]}, {"rule_set"})
|
||||
self.assertTrue(frozen["content_hash"])
|
||||
# 原生方法文本在装配时即固化,运行中改动无法影响本次
|
||||
model_skills = [s for s in frozen["skills"] if s.get("native_skills")]
|
||||
self.assertTrue(model_skills)
|
||||
self.assertTrue(all(s["methods"] for s in model_skills))
|
||||
|
||||
def test_unpublished_plan_is_rejected_with_a_clear_reason(self):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.assemble("corporate.does-not-exist")
|
||||
self.assertIn("评审方案不可用", str(ctx.exception))
|
||||
|
||||
def test_draft_dependency_is_named_in_the_error(self):
|
||||
# 造一个未发布的 skill 并被方案引用 → 必须指出是哪一项
|
||||
with store.transaction(self.db_path) as db:
|
||||
plan_body = registry.resolve(db, "corporate.default")
|
||||
skill_id = plan_body["skills"][0]
|
||||
# 移除该 skill 的发布指针,模拟"草稿/已撤回"
|
||||
db.execute("DELETE FROM assistant_objects WHERE kind='release' AND id=?", (skill_id,))
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.assemble()
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("不可用依赖", message)
|
||||
self.assertIn(skill_id, message, "必须指明是哪个依赖不可用")
|
||||
self.assertIn("尚未发布或已撤回", message)
|
||||
|
||||
def test_cross_domain_dependency_is_rejected(self):
|
||||
# 演示与正式组件不能混用(freeze 的既有保证)
|
||||
with store.transaction(self.db_path) as db:
|
||||
production = dict(registry.resolve(db, "corporate.analysis"), scope="production")
|
||||
production.pop("revision", None)
|
||||
store.put(db, "component_version", production, immutable=True)
|
||||
store.put(db, "release", {"id": "corporate.analysis", "version_id": production["id"],
|
||||
"scope": "production"})
|
||||
body = registry.resolve(db, "corporate.default")
|
||||
body["skills"] = ["corporate.analysis"] + [s for s in body["skills"] if s != "corporate.analysis"]
|
||||
with self.assertRaises(ValueError):
|
||||
with store.transaction(self.db_path) as db:
|
||||
plan.check_dependencies(db, body)
|
||||
registry.freeze(db, "corporate.default")
|
||||
|
||||
def test_frozen_hash_changes_when_a_dependency_version_changes(self):
|
||||
first = self.assemble()
|
||||
with store.transaction(self.db_path) as db:
|
||||
upgraded = dict(registry.resolve(db, "corporate.cashflow"), version="9.9.9",
|
||||
name="现金流完整性(改版)")
|
||||
upgraded.pop("revision", None)
|
||||
store.put(db, "component_version", upgraded, immutable=True)
|
||||
store.put(db, "release", {"id": "corporate.cashflow", "version_id": upgraded["id"],
|
||||
"scope": upgraded["scope"]})
|
||||
second = self.assemble()
|
||||
self.assertNotEqual(first["content_hash"], second["content_hash"],
|
||||
"换版本后装配结果必须变化,证明版本确实被冻结进结果")
|
||||
|
||||
def test_assembled_dimensions_are_resolved_and_presented(self):
|
||||
with store.transaction(self.db_path) as db:
|
||||
body = dict(registry.resolve(db, "corporate.default"),
|
||||
dimensions=[spec("d1"), spec("d2", "负债率", 1), spec("d3", "设备", 2)])
|
||||
body.pop("revision", None)
|
||||
store.put(db, "component_version", body, immutable=True)
|
||||
store.put(db, "release", {"id": "corporate.default", "version_id": body["id"],
|
||||
"scope": body["scope"]})
|
||||
frozen = plan.assemble(db, "corporate.default")
|
||||
self.assertEqual(len(frozen["dimensions"]), 3)
|
||||
self.assertEqual(frozen["presentation"]["layout"], "radar")
|
||||
|
||||
def test_conflicting_dimensions_are_rejected_at_assembly(self):
|
||||
with store.transaction(self.db_path) as db:
|
||||
body = dict(registry.resolve(db, "corporate.default"),
|
||||
dimensions=[spec("d1"), spec("d1", label="同名不同义")])
|
||||
body.pop("revision", None)
|
||||
store.put(db, "component_version", body, immutable=True)
|
||||
store.put(db, "release", {"id": "corporate.default", "version_id": body["id"],
|
||||
"scope": body["scope"]})
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
plan.assemble(db, "corporate.default")
|
||||
self.assertIn("不一致定义", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user