aps-agent/tests/golden/test_checkpoint_projection.py

243 lines
12 KiB
Python
Raw Normal View History

# ============================================================
# 检查点世界侧投影黄金测试(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"]