# ============================================================ # 工程图纸 DXF 自动识别黄金测试(domain-dxf-drawing) # 覆盖:样例图纸解析(标题栏/位置号/技术说明)、文件名交叉校验、 # 物料/BOM/工艺路线候选安全边界(missing 不编造)、SVG 预览、 # 工程目录发现、多模态提取器接线与入库 fail closed、 # /api/dxf/* 端点、P2 确认卡 staging 与确认后写主数据。 # 样例:demand/dxf-samples/5060102101-001-e.dxf # (MTU 12V 183 TB32 发动机安装图,AC1015,德英双语标题栏) # ============================================================ from __future__ import annotations import os import pytest from fastapi.testclient import TestClient from server.agent_core import harness from server.aps_domain.dxf_drawing import ( discover_dxf_files, drawing_to_master_candidates, dxf_svg_cached, dxf_to_svg, get_cached_drawing, parse_dxf, parse_dxf_bytes, ) from server.aps_domain.masterdata import confirmation_for_master_action from server.aps_domain.multimodal import candidates_to_batches, default_registry from server.aps_domain.workflow import execute_confirmed from server.auth.context import IdentityContext, bind_identity, reset_identity from server.state.seed import seed_world _SAMPLE = os.path.join(os.path.dirname(__file__), "..", "..", "demand", "dxf-samples", "5060102101-001-e.dxf") _SAMPLE = os.path.abspath(_SAMPLE) _PLANNER = IdentityContext(1001, "planner", "计划员", "tenant-dxf", roles=("planner",)) def _next_id(world): counters: dict[str, int] = {} def next_id(kind: str) -> int: table = "auditEvents" if kind == "audit" else kind + "s" if kind not in counters: rows = world.get(table, []) counters[kind] = max((r.get("id", 0) for r in rows if isinstance(r, dict)), default=0) counters[kind] += 1 return counters[kind] return next_id class _WorldStore: def __init__(self, world, next_id_fn): self.data = world self._next_id_fn = next_id_fn self.tenant_uuid = "tenant-dxf" self.world_key = "personal-1001" def next_id(self, kind: str) -> int: return self._next_id_fn(kind) def save(self) -> None: pass @pytest.fixture(scope="module") def parsed(): assert os.path.isfile(_SAMPLE), f"样例图纸缺失:{_SAMPLE}" return parse_dxf(_SAMPLE) # ---------------- 解析:标题栏 / 位置号 / 技术说明 ---------------- def test_parse_sample_title_block(parsed): assert parsed["dxfVersion"] == "AC1015" tb = parsed["titleBlock"] assert tb["found"] is True assert tb["source"] == "block-attribs" assert tb["blockName"] == "SF_STD_A" fields = tb["fields"] dn = fields["drawingNumber"] assert dn["value"] == "506 010 21 01" assert dn["normalized"] == "5060102101" assert dn["confidence"] == pytest.approx(0.95) # 属性 + 文件名一致互证 assert "12V 183 TB32" in fields["title"]["value"] assert fields["scale"]["value"] == "1:5" assert fields["sheet"]["value"] == "1" assert fields["drawingType"]["value"] == "EB/Installation" assert fields["applicableTo"]["value"] == "OM 444 LA" assert fields["revision"]["value"] == "e" # 文件名后缀 + B_FREI 互证 assert fields["revision"]["confidence"] == pytest.approx(0.9) # 未填写字段保持空 + missing(不编造材质) assert fields["materialSpec"]["value"] == "" assert fields["materialSpec"]["missing"] is True def test_parse_positions_notes_and_stats(parsed): positions = {p["position"] for p in parsed["positionNumbers"]} assert {"14.001", "06.020", "15.108", "09.152"} <= positions assert len(parsed["positionNumbers"]) >= 30 assert len(parsed["technicalNotes"]) >= 5 assert parsed["entityStats"]["LINE"] > 0 assert parsed["entityStats"]["ARC"] > 0 assert any(l["name"] == "RAHM2" for l in parsed["layers"]) assert parsed["extents"]["maxX"] > parsed["extents"]["minX"] assert parsed["warnings"] == [] def test_parse_fail_closed(): with pytest.raises(ValueError, match="不存在"): parse_dxf(r"D:\no-such-dir\no-such-file.dxf") bad = os.path.join(os.path.dirname(_SAMPLE), "not-a-dxf.dxf") with open(bad, "w", encoding="utf-8") as fh: fh.write("这不是 DXF 内容") try: with pytest.raises(ValueError, match="解析失败"): parse_dxf(bad) finally: os.unlink(bad) def test_parse_bytes_uses_display_name(): with open(_SAMPLE, "rb") as fh: data = fh.read() parsed = parse_dxf_bytes("5060102101-001-e.dxf", data) assert parsed["filename"] == "5060102101-001-e.dxf" assert parsed["fileMeta"]["revision"] == "e" assert parsed["titleBlock"]["fields"]["revision"]["value"] == "e" entry = get_cached_drawing(parsed["drawingId"]) assert entry is not None and entry.get("tempPath") # ---------------- 候选:安全边界(missing 不编造) ---------------- def test_master_candidates_boundary(parsed): cands = drawing_to_master_candidates(parsed) material = cands["material"] assert material["code"] == "5060102101-001" # 张次沿用文件名 001 写法 assert "12V 183 TB32" in material["name"] assert material["type"] == "FINISHED_PRODUCT" # 安装图推断(置信封顶 0.6) assert material["requiresConfirm"] is True assert material["confidence"] <= 0.95 assert material["materialSpec"] == "" # 图纸未承载:不编造材质 assert any("材质" in x for x in material["notFromDrawing"]) bom = cands["bom"] assert bom["complete"] is False assert bom["requiresConfirm"] is True assert len(bom["items"]) >= 30 item = bom["items"][0] assert item["missing"] == ["materialCode", "name", "quantity"] assert item["quantity"] is None # 数量绝不臆造 routing = cands["routing"] assert routing["complete"] is False assert routing["steps"] == [] # 工序/工时留空待人工编制 assert "人工确认" in routing["note"] # ---------------- SVG 预览与目录发现 ---------------- def test_svg_preview(parsed): svg = dxf_svg_cached(parsed["drawingId"]) assert svg.startswith(" 100_000 svg2 = dxf_to_svg(_SAMPLE) assert "= 1 hit = next(f for f in result["files"] if f["filename"] == "5060102101-001-e.dxf") assert hit["fileMeta"]["drawingNumberNormalized"] == "5060102101" assert hit["fileMeta"]["sheet"] == "001" assert hit["fileMeta"]["revision"] == "e" with pytest.raises(ValueError, match="不存在"): discover_dxf_files(r"D:\no-such-dir\x") # ---------------- 多模态接线:提取可用 / 入库 fail closed ---------------- def test_multimodal_dxf_extractor(): registry = default_registry(world=seed_world()) assert "dxf_drawing" in registry.list_kinds() cands = registry.extract("dxf_drawing", {"path": _SAMPLE}) assert len(cands) == 1 c = cands[0] assert c.kind == "dxf_drawing" and c.source == "DXF_PARSE" assert c.complete is True assert c.confidence >= 0.9 assert c.value["material"]["code"] == "5060102101-001" assert c.value["positionCount"] >= 30 # DXF 候选不经 importers 批次:显式报错并指引 /api/dxf/stage with pytest.raises(ValueError, match="/api/dxf/stage"): candidates_to_batches([c]) # ---------------- P2 确认卡:确认前不写世界,确认后写主数据 ---------------- def test_dxf_stage_then_confirm_writes_material(parsed): """候选 → master.material.upsert P2 卡 → 批准写物料 / 驳回不写。""" world = seed_world() next_id = _next_id(world) material = drawing_to_master_candidates(parsed)["material"] payload = { "code": material["code"], "name": material["name"], "type": material["type"], "unit": material["unit"], "spec": material["spec"], } title, lines = confirmation_for_master_action( world, "master.material.upsert", payload) assert material["code"] in title or material["name"] in title assert lines token = bind_identity(_PLANNER) try: block = harness.stage_confirmation( "dxf-test", "master.material.upsert", payload, title=title, summary_lines=lines) cid = str(block.props["confirmId"]) assert not any(m["code"] == material["code"] for m in world["materials"]) store = _WorldStore(world, next_id) message = execute_confirmed(store, cid, True, actor="planner") assert "物料" in message or "完成" in message written = next(m for m in world["materials"] if m["code"] == material["code"]) assert written["name"] == material["name"] assert any(e["action"] == "master.material.upsert" for e in world["auditEvents"]) finally: reset_identity(token) def test_dxf_stage_validation_fail_closed(parsed): """缺名称/编码的候选不出卡:confirmation_for_master_action 直接报错。""" world = seed_world() with pytest.raises(ValueError): confirmation_for_master_action( world, "master.material.upsert", {"code": "", "name": ""}) # ---------------- 网关接线:/api/dxf/* ---------------- class _AuditStore: def __init__(self, tenant_uuid: str = "tenant-dxf"): self.tenant_uuid = tenant_uuid self.world_key = "personal-1001" self.data: dict = seed_world() self._counter = 0 def next_id(self, _kind: str) -> int: self._counter += 1 return self._counter def save(self) -> None: pass class _CheckpointStore: def create(self, *_args, **_kwargs) -> dict: return {"pairId": "dxf-pair"} def get(self, pair_id: str) -> dict | None: return {"pairId": pair_id} class _ProjectStore: def active_world_key(self) -> str: return "default" def require_active_write(self) -> None: pass @pytest.fixture def client_factory(monkeypatch): import server.gateway.app as gateway_module import server.state.projects as projects_module from tests.auth_provider import install_test_auth provider = install_test_auth(monkeypatch, "tenant-dxf") def factory(): store = _AuditStore() monkeypatch.setattr(gateway_module, "get_store", lambda: store) monkeypatch.setattr(gateway_module, "get_checkpoints", lambda: _CheckpointStore()) monkeypatch.setattr(projects_module, "get_project_store", lambda: _ProjectStore()) client = TestClient(gateway_module.create_app()) login = client.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text return client, store, provider return factory def test_api_parse_path_svg_and_discover(client_factory): client, store, _ = client_factory() resp = client.post("/api/dxf/parse-path", json={"path": _SAMPLE}) assert resp.status_code == 200, resp.text data = resp.json() drawing = data["drawing"] assert drawing["titleBlock"]["fields"]["drawingNumber"]["normalized"] == "5060102101" assert data["candidates"]["material"]["code"] == "5060102101-001" assert any(e["action"] == "dxf.parse" for e in store.data["auditEvents"]) svg_resp = client.get(f"/api/dxf/{drawing['drawingId']}/svg") assert svg_resp.status_code == 200 assert svg_resp.headers["content-type"].startswith("image/svg+xml") assert "= 1 bad = client.post("/api/dxf/parse-path", json={"path": r"D:\no-such\x.dxf"}) assert bad.status_code == 200 and "error" in bad.json() missing = client.get("/api/dxf/ffffffffffffffff/svg") assert missing.status_code == 404 def test_api_dxf_stage_material_card(client_factory): client, store, _ = client_factory() resp = client.post("/api/dxf/parse-path", json={"path": _SAMPLE}) drawing = resp.json()["drawing"] material = resp.json()["candidates"]["material"] before = len(store.data["materials"]) stage = client.post("/api/dxf/stage", json={ "drawingId": drawing["drawingId"], "target": "material", "payload": {"code": material["code"], "name": material["name"], "type": material["type"], "unit": material["unit"], "spec": material["spec"]}, }) assert stage.status_code == 200, stage.text body = stage.json() assert "P2 确认队列" in body["message"] assert body["block"]["props"]["confirmId"] assert len(store.data["materials"]) == before # 确认前绝不写世界 assert any(e["action"] == "dxf.stage.material" for e in store.data["auditEvents"]) bad = client.post("/api/dxf/stage", json={ "target": "material", "payload": {"code": "", "name": ""}}) assert "error" in bad.json() unknown = client.post("/api/dxf/stage", json={"target": "magic", "payload": {}}) assert "error" in unknown.json() def test_svg_layer_filter_variant(parsed): """图层开关:过滤渲染显著小于全量渲染,且变体缓存命中。""" all_svg = dxf_svg_cached(parsed["drawingId"]) only6 = dxf_svg_cached(parsed["drawingId"], include_layers=["6"]) assert "