# ============================================================ # 检查点世界侧投影黄金测试(moduleId: state-checkpoints, 矩阵 54 行剩余项) # 覆盖:建档 → 投影含 summary + 固定/柔性工单 + 产能池字段; # 缺失 pair 显式失败(KeyError / HTTP 404 + 中文提示); # 投影不改动仓内数据(深拷贝隔离); # 分支锚定批量投影(Sagan #2:分支 checkpointId → 投影)。 # ============================================================ from __future__ import annotations import json import pytest from fastapi.testclient import TestClient from server.state.checkpoints import CheckpointStore, world_projection from server.state.seed import seed_world # ---------------- 夹具世界:固定轨 + 柔性轨双轨排产产物 ---------------- def _dual_track_world(): """演示种子世界 + 手工排产产物(固定轨 scheduleVersions + 柔性轨 flexScheduleVersions)。""" world = seed_world() world["scheduleVersions"] = [{ "id": 1, "versionNo": "V1", "status": "PUBLISHED", "createdAt": "2026-08-01 09:00", "woCount": 2, "poCount": 1, "conflictCount": 1, "totalTardiness": 3.5, "avgUtilization": 0.82, "totalCost": 1234, "engineType": "RULE", }] world["productionOrders"] = [{ "id": 1, "orderNo": "PO-0001", "salesOrderId": 1, "productId": 1, "productName": "智能控制器A型", "productCode": "CTRL-A", "quantity": 100, "unit": "件", "lineId": 1, "lineName": "SMT线1", "status": "DRAFT", "priority": 1, "schedulingVersionId": 1, "materialKitStatus": "PASSED", "conflictCount": 0, "plannedEndDate": "2026-08-10", }] world["workOrders"] = [ {"id": 1, "orderNo": "PO-0001-01", "productionOrderId": 1, "productionOrderNo": "PO-0001", "operationId": 1, "operationName": "SMT贴片", "sequenceNo": 1, "productId": 1, "productName": "智能控制器A型", "quantity": 100, "unit": "件", "lineId": 1, "lineName": "SMT线1", "workstationId": 1, "workstationName": "SMT-01", "plannedStartTime": "2026-08-03 08:00", "plannedEndTime": "2026-08-03 10:00", "status": "PENDING", "kitStatus": "PASSED", "progressPercent": 0, "conflictCount": 0}, {"id": 2, "orderNo": "PO-0001-02", "productionOrderId": 1, "productionOrderNo": "PO-0001", "operationId": 2, "operationName": "DIP插件", "sequenceNo": 2, "productId": 1, "productName": "智能控制器A型", "quantity": 100, "unit": "件", "lineId": 1, "lineName": "SMT线1", "workstationId": 2, "workstationName": "DIP-01", "plannedStartTime": "2026-08-03 10:10", "plannedEndTime": "2026-08-03 12:00", "status": "PENDING", "kitStatus": "PASSED", "progressPercent": 0, "conflictCount": 1}, ] # 柔性轨:挂一版柔性排产,压接工序落在种子设备 PRESS-01(OP-CRIMP 能力池) world["flexScheduleVersions"] = [{ "id": 7, "versionNo": "F-V1", "sortMode": "DUE_FIRST", "createdAt": "2026-08-01 10:00", }] world["flexWorkOrders"] = [{ "id": 91, "versionId": 7, "flexOrderNo": "FO-2601", "orderNo": "FO-2601-01", "productCode": "HV-HARNESS", "quantity": 100, "operationName": "压接端子", "seq": 1, "equipmentId": 3, "equipmentCode": "PRESS-01", "equipmentName": "气动压接机#1", "zone": "ZONE-A", "moldCode": "MOLD-HV-01", "changeoverMin": 20, "moveMin": 10, "runMin": 100, "plannedStartTime": "2026-08-03 08:00", "plannedEndTime": "2026-08-03 09:40", "isBottleneck": True, }] return world def _make_store(tmp_path, world): """临时仓 + 建档,返回 (store, pair_id, 仓指纹)。""" store = CheckpointStore(path=str(tmp_path / "ckpt.json")) meta = store.create(world, label="投影基线", reason="manual") fingerprint = json.dumps(store.pairs, ensure_ascii=False, sort_keys=True) return store, meta["pairId"], fingerprint # ---------------- 1. 投影包含 summary + 固定/柔性工单 + 产能池 ---------------- def test_checkpoint_world_projection_contains_summary_and_both_tracks(tmp_path): """矩阵 54 行:投影含 summary(KPI+双轨计数)、固定轨工单(订单/工序/开始/结束/设备/模具/交期/状态)、 柔性轨工单与产能池——足够前端逐字段 diff。""" world = _dual_track_world() store, pair_id, _ = _make_store(tmp_path, world) proj = store.world_projection(pair_id) s = proj["summary"] assert s["hasVersion"] is True assert s["versionNo"] == "V1" and s["status"] == "PUBLISHED" assert s["woCount"] == 2 and s["conflictCount"] == 1 assert s["totalTardiness"] == 3.5 and s["avgUtilization"] == 0.82 # 双轨计数(前端 WorldStats 数据源) assert s["flexVersionNo"] == "F-V1" and s["flexSortMode"] == "DUE_FIRST" assert s["flexWoCount"] == 1 and s["flexOverdue"] == 0 and s["flexConflict"] == 0 assert s["fixedWoCount"] == 2 assert s["poolCount"] > 0 and s["bottleneckDaily"] is not None # 固定轨工单:订单/工序/开始/结束/设备/模具/交期/状态 fixed = proj["gantt"]["workOrders"] assert len(fixed) == 2 wo = next(w for w in fixed if w["orderNo"] == "PO-0001-01") assert wo["operationName"] == "SMT贴片" assert wo["start"] == "2026-08-03 08:00" and wo["end"] == "2026-08-03 10:00" assert wo["workstationName"] == "SMT-01" assert wo["mold"] is None # 固定轨无模具概念 due = next(so for so in world["salesOrders"] if so["id"] == 1)["deliveryDate"] assert wo["deliveryDate"] == due # 交期(销售订单 → 生产订单) assert wo["status"] == "PENDING" # 状态(工单) # 柔性轨工单:含模具/设备/工序序号 flex = proj["flexGantt"]["workOrders"] assert len(flex) == 1 fwo = flex[0] assert fwo["orderNo"] == "FO-2601" and fwo["operationName"] == "压接端子" and fwo["seq"] == 1 assert fwo["equipmentCode"] == "PRESS-01" assert fwo["moldCode"] == "MOLD-HV-01" assert fwo["start"] == "2026-08-03 08:00" and fwo["end"] == "2026-08-03 09:40" assert fwo["dueDate"] # 交期(柔性订单)已富化 # 产能池:设备数 + 预测利用率 pools = proj["flexCapacity"]["pools"] assert pools, "产能池应非空(种子 OP-CRIMP 能力池)" crimp = next(p for p in pools if p["operationCode"] == "OP-CRIMP") assert crimp["equipmentCount"] >= 4 assert crimp["forecast"]["d1"]["utilization"] >= 0 # ---------------- 2. 缺失 pair 显式失败 ---------------- def test_checkpoint_world_projection_missing_pair_fails(tmp_path): """缺失 pair:投影函数显式抛 KeyError(NotFound 语义,由网关转 404)。""" store = CheckpointStore(path=str(tmp_path / "ckpt.json")) store.create(_dual_track_world(), label="基线", reason="manual") with pytest.raises(KeyError): store.world_projection("no-such-pair") with pytest.raises(KeyError): world_projection("no-such-pair", store=store) # ---------------- 3. 投影不改动仓内数据(深拷贝) ---------------- def test_checkpoint_world_projection_does_not_mutate_store(tmp_path): """投影在快照世界深拷贝上计算:仓内 pairs 逐字节不变,含被视图可能补种的柔性层。""" store, pair_id, fingerprint = _make_store(tmp_path, _dual_track_world()) store.world_projection(pair_id) # 投影(内部会跑 flex_gantt_view / capacity_analysis) after = json.dumps(store.pairs, ensure_ascii=False, sort_keys=True) assert after == fingerprint, "world_projection 改动了仓内数据(违反深拷贝隔离)" # ---------------- 4. HTTP 端点:单查 + 404 + 分支锚定批量 ---------------- @pytest.fixture() def secure_app(tmp_path, monkeypatch): monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db")) monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json")) monkeypatch.setenv("APS_BRANCH_DIR", str(tmp_path / "branches")) monkeypatch.setenv("APS_CHECKPOINT_PATH", str(tmp_path / "ckpt.json")) import server.state.checkpoints as ck_mod from server.db.database import reset_engine from server.state import store as world_store from server.state.branches import reset_branch_stores from tests.auth_provider import install_test_auth from tests.pi_primary_adapter import install_fake_pi_tool_adapter install_test_auth(monkeypatch, "tenant-a-000000000000000000000001") install_fake_pi_tool_adapter(monkeypatch) world_store._stores.clear() ck_mod._checkpoints.clear() reset_branch_stores() reset_engine() from server.gateway.app import create_app app = create_app() yield app reset_engine() world_store._stores.clear() def _login(client: TestClient, username: str = "planner") -> None: r = client.post("/api/auth/login", json={ "method": "password", "username": username, "password": "test", }) assert r.status_code == 200 def _seed_checkpoint(client: TestClient) -> tuple[str, str, str]: """真实链路建档:建项目 → 排产 → 建检查点 → 返回 (sessionId, pairId, branchId)。""" created = client.post("/api/projects", json={"id": "proj-proj", "name": "投影验收项目"}) assert created.status_code == 200 sid = created.json()["session"]["id"] sched = client.post("/api/chat", json={"sessionId": sid, "text": "试排一版交期优先"}) assert sched.status_code == 200, sched.text[:200] chat = client.post("/api/chat", json={"sessionId": sid, "text": "建一个检查点"}) assert chat.status_code == 200, chat.text[:200] tree = client.get(f"/api/sessions/{sid}/branches").json()["tree"] active = next(b for b in tree["branches"] if b["id"] == tree["activeNode"]) pair_id = active["checkpointId"] assert pair_id, "建档后活动分支应锚定检查点" return sid, pair_id, active["id"] def test_checkpoint_projection_api_single_and_404(secure_app): """GET /api/checkpoints/{pairId} 返回 {meta, worldProjection};缺失 pair 404 + 中文提示。""" client = TestClient(secure_app) _login(client) sid, pair_id, _ = _seed_checkpoint(client) r = client.get(f"/api/checkpoints/{pair_id}") assert r.status_code == 200, r.text[:300] body = r.json() assert body["meta"]["pairId"] == pair_id proj = body["worldProjection"] assert proj["summary"]["hasVersion"] is True assert "gantt" in proj and "flexGantt" in proj and "flexCapacity" in proj # 固定轨工单已排产(chat 真实排产),投影应带出订单/工序/时间字段 fixed = proj["gantt"]["workOrders"] assert fixed, "chat 排产后固定轨工单投影应非空" for wo in fixed: assert {"orderNo", "operationName", "start", "end"} <= wo.keys() assert "deliveryDate" in wo and "status" in wo and "mold" in wo missing = client.get("/api/checkpoints/does-not-exist") assert missing.status_code == 404 assert "检查点不存在" in missing.json()["detail"] def test_checkpoint_projection_api_branch_anchor_batch(secure_app): """GET /api/checkpoints?branchIds=&sessionId= 按分支锚点批量投影(Sagan #2:分支 vs 分支 diff)。""" client = TestClient(secure_app) _login(client) sid, pair_id, branch_id = _seed_checkpoint(client) r = client.get("/api/checkpoints", params={"branchIds": branch_id, "sessionId": sid}) assert r.status_code == 200, r.text[:300] body = r.json() assert body["missing"] == [] hit = body["projections"].get(branch_id) assert hit and hit["meta"]["pairId"] == pair_id assert hit["worldProjection"]["summary"]["hasVersion"] is True # pairIds 批量 + 缺失项上报 r2 = client.get("/api/checkpoints", params={"pairIds": f"{pair_id},nope-1,nope-2"}) body2 = r2.json() assert body2["projections"][pair_id]["meta"]["pairId"] == pair_id assert sorted(body2["missing"]) == ["nope-1", "nope-2"] # 未知分支 id → missing r3 = client.get("/api/checkpoints", params={"branchIds": "ghost-branch", "sessionId": sid}) assert r3.status_code == 200 assert r3.json()["missing"] == ["ghost-branch"]