162 lines
9.4 KiB
Python
162 lines
9.4 KiB
Python
# ============================================================
|
||
# RULE 引擎黄金测试(moduleId: golden-rule-engine, 发版门禁 §14.3)
|
||
# 断言"结构不变式"而非绝对日期(种子日期相对今天,任意运行日稳定):
|
||
# G1 版本/PO/WO 计数关系 G2 工位无双占(硬约束 §3.5)
|
||
# G3 工艺先后序不颠倒 G4 工单落在班次日历内(或兜底可解释)
|
||
# G5 策略排序生效 G6 冲突结构合法 G7 KPI 值域合法
|
||
# ============================================================
|
||
from __future__ import annotations # 前向类型引用
|
||
|
||
import pytest # 测试框架
|
||
|
||
from server.engines import get_engine # 引擎工厂
|
||
from server.engines.base import EngineParams # 引擎入参
|
||
from server.engines.queries import get_line_shifts # 班次查询(G4 用)
|
||
from server.state.seed import seed_world # 种子数据(每用例全新世界)
|
||
from server.timeutil import add_minutes, fmt_date, parse_dt, today0 # 日期工具
|
||
|
||
|
||
def _fresh_world():
|
||
"""构造一个全新种子世界(用例间隔离,确定性)。"""
|
||
return seed_world() # 直接返回种子
|
||
|
||
|
||
def _next_id_factory():
|
||
"""独立发号器(不依赖 WorldStore,保证用例纯内存)。"""
|
||
counters: dict[str, int] = {} # 类型 → 当前号
|
||
def next_id(kind: str) -> int: # 闭包发号函数
|
||
counters[kind] = counters.get(kind, 0) + 1 # 自增
|
||
return counters[kind] # 返回新号
|
||
return next_id # 返回闭包
|
||
|
||
|
||
def _run(world, strategy="COMPREHENSIVE", engine="RULE"):
|
||
"""执行一次排产(默认从明天开始、展望 14 天)。"""
|
||
start = fmt_date(add_minutes(today0(), 24 * 60)) # 明天
|
||
params = EngineParams(orderIds=[], engineType=engine, strategyTemplate=strategy,
|
||
planningHorizonDays=14, startDate=start) # 组装参数
|
||
return get_engine(engine).solve(world, params, _next_id_factory()) # 求解
|
||
|
||
|
||
# ---------------- G1:计数关系 ----------------
|
||
def test_counts_match_seed_structure():
|
||
"""版本计数必须与种子结构一致:7 单皆可排 → 7 个 PO;工单数 = 各产品工艺步骤数之和。"""
|
||
world = _fresh_world() # 全新世界
|
||
result = _run(world) # 执行排产
|
||
assert result.orderCount == 7 # 7 张订单全部参与(无 CANCELLED/COMPLETED)
|
||
assert result.poCount == 7 # 每单一项 → 7 个生产订单
|
||
# 工单数 = Σ 该订单产品的工艺步骤数(A=5 步,B=4 步,C=4 步;种子订单产品为 1,2,1,3,2,3,2)
|
||
expected_wo = 5 + 4 + 5 + 4 + 4 + 4 + 4 # 逐单累加 = 30
|
||
assert result.woCount == expected_wo # 工单总数固定
|
||
assert len([v for v in world["scheduleVersions"]]) == 1 # 恰生成一个版本
|
||
|
||
|
||
# ---------------- G2:工位无双占(硬约束) ----------------
|
||
def test_no_workstation_overlap():
|
||
"""同一工位的任意两工单时间区间不得重叠(§3.5 资源硬约束)。"""
|
||
world = _fresh_world() # 全新世界
|
||
_run(world) # 执行排产
|
||
by_ws: dict[int, list] = {} # 工位 → [(start,end)]
|
||
for wo in world["workOrders"]: # 收集全部工单区间
|
||
by_ws.setdefault(wo["workstationId"], []).append(
|
||
(parse_dt(wo["plannedStartTime"]), parse_dt(wo["plannedEndTime"])))
|
||
for ws_id, ivs in by_ws.items(): # 逐工位校验
|
||
ivs.sort() # 按开始时间排序
|
||
for (s1, e1), (s2, e2) in zip(ivs, ivs[1:]): # 相邻两两比较
|
||
assert e1 <= s2, f"工位 {ws_id} 存在双占:{e1} > {s2}" # 前段结束不得晚于后段开始
|
||
|
||
|
||
# ---------------- G3:工艺先后序 ----------------
|
||
def test_operation_sequence_preserved():
|
||
"""同一生产订单内,工序顺序号大的工单开始时间不得早于顺序号小的结束时间。"""
|
||
world = _fresh_world() # 全新世界
|
||
_run(world) # 执行排产
|
||
by_po: dict[int, list] = {} # PO → 工单列表
|
||
for wo in world["workOrders"]: # 归组
|
||
by_po.setdefault(wo["productionOrderId"], []).append(wo)
|
||
for po_id, wos in by_po.items(): # 逐 PO 校验
|
||
wos.sort(key=lambda w: w["sequenceNo"]) # 按工艺顺序排列
|
||
for prev, nxt in zip(wos, wos[1:]): # 相邻工序比较
|
||
assert parse_dt(nxt["plannedStartTime"]) >= parse_dt(prev["plannedEndTime"]), \
|
||
f"PO {po_id} 工序颠倒:{nxt['orderNo']} 早于 {prev['orderNo']} 结束" # 后序不得早于前序结束
|
||
|
||
|
||
# ---------------- G4:班次日历约束 ----------------
|
||
def test_work_orders_within_shift_calendar():
|
||
"""工单开始日应为该产线的工作日(兜底顺延的极端情形除外——种子规模下不应触发)。"""
|
||
world = _fresh_world() # 全新世界
|
||
_run(world) # 执行排产
|
||
for wo in world["workOrders"]: # 逐工单校验
|
||
date_str = wo["plannedStartTime"][:10] # 开始日期
|
||
shifts = get_line_shifts(world, wo["lineId"], date_str) # 当日班次
|
||
# 种子数据 14 天展望内产能充足,不应触发"30 天无槽"兜底 → 必须落在工作日
|
||
assert shifts, f"工单 {wo['orderNo']} 落在非工作日 {date_str}"
|
||
|
||
|
||
# ---------------- G5:策略排序生效 ----------------
|
||
def test_delivery_first_orders_by_due_date():
|
||
"""交期优先策略下,最早交期订单(比亚迪 +4 天)的 PO 应最先开始。"""
|
||
world = _fresh_world() # 全新世界
|
||
_run(world, strategy="DELIVERY_FIRST") # 交期优先排产
|
||
pos = world["productionOrders"] # 全部 PO
|
||
earliest = min(pos, key=lambda p: parse_dt(p["plannedStartDate"])) # 最早开始的 PO
|
||
so = next(s for s in world["salesOrders"] if s["id"] == earliest["salesOrderId"]) # 对应订单
|
||
min_delivery = min(s["deliveryDate"] for s in world["salesOrders"]) # 全局最早交期
|
||
assert so["deliveryDate"] == min_delivery # 最早开始的必须是最早交期的订单(EDD)
|
||
|
||
|
||
# ---------------- G6:冲突结构合法 ----------------
|
||
def test_conflicts_are_wellformed():
|
||
"""所有冲突必须归属本版本、未解决、类型/级别在枚举内(§3.4 证据可溯的前提)。"""
|
||
world = _fresh_world() # 全新世界
|
||
result = _run(world) # 执行排产
|
||
valid_types = {"NO_LINE", "NO_WORKSTATION", "MATERIAL_SHORTAGE", "DELAY", "CAPACITY", "EQUIPMENT"} # 类型枚举
|
||
valid_sev = {"CRITICAL", "MAJOR", "MINOR"} # 级别枚举
|
||
assert len(world["conflicts"]) == result.conflictCount # 计数一致
|
||
for c in world["conflicts"]: # 逐条校验
|
||
assert c["versionId"] == result.versionId # 归属本版本
|
||
assert c["isResolved"] is False # 初始未解决
|
||
assert c["conflictType"] in valid_types # 类型合法
|
||
assert c["severity"] in valid_sev # 级别合法
|
||
|
||
|
||
# ---------------- G7:KPI 值域 ----------------
|
||
def test_kpi_ranges():
|
||
"""KPI 必须在合法值域:利用率 0~1.5(容差),延迟/成本非负。"""
|
||
world = _fresh_world() # 全新世界
|
||
result = _run(world) # 执行排产
|
||
assert 0 <= result.avgUtilization <= 1.5 # 利用率合理(>1 表示超载但不应爆炸)
|
||
assert result.totalTardiness >= 0 # 延迟非负
|
||
assert result.totalCost >= 0 # 成本非负
|
||
|
||
|
||
# ---------------- G8:引擎类型标注诚实 ----------------
|
||
@pytest.mark.parametrize("requested", ["RULE", "GA"])
|
||
def test_engine_type_recorded_for_rule_proxy(requested):
|
||
"""GA 仍由 RULE 代跑,但结果必须如实记录请求类型。"""
|
||
world = _fresh_world()
|
||
result = _run(world, engine=requested)
|
||
assert result.engineType == requested
|
||
assert world["scheduleVersions"][0]["engineType"] == requested
|
||
|
||
|
||
def test_cp_engine_is_real_not_silent_rule():
|
||
"""SC-03:请求 CP 必须走 CpSatEngine,不得静默 RULE 代跑却不留 solverMeta。"""
|
||
from server.engines.cp_engine import CpSatEngine
|
||
assert isinstance(get_engine("CP"), CpSatEngine)
|
||
world = _fresh_world()
|
||
result = _run(world, engine="CP")
|
||
assert result.engineType == "CP"
|
||
assert world["scheduleVersions"][0].get("solverMeta", {}).get("backend") == "OR-Tools CP-SAT"
|
||
|
||
|
||
def test_hybrid_engine_is_real_not_silent_rule():
|
||
"""SC-03:HYBRID 必须走 HybridEngine(RULE 热启动 + CP)。"""
|
||
from server.engines.cp_engine import HybridEngine
|
||
assert isinstance(get_engine("HYBRID"), HybridEngine)
|
||
world = _fresh_world()
|
||
result = _run(world, engine="HYBRID")
|
||
assert result.engineType == "HYBRID"
|
||
meta = world["scheduleVersions"][0].get("solverMeta") or {}
|
||
assert meta.get("warmStart") == "RULE"
|