# ============================================================ # SC-03 CP-SAT 引擎黄金测试 # ============================================================ from __future__ import annotations import pytest from server.engines import get_engine from server.engines.base import EngineParams from server.engines.cp_engine import CpSatEngine from server.engines.rule_engine import RuleEngine from server.state.seed import seed_world from server.timeutil import add_minutes, fmt_date, parse_dt, today0 def _next_id_factory(): 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="CP", time_limit=8.0): start = fmt_date(add_minutes(today0(), 24 * 60)) params = EngineParams( orderIds=[], engineType=engine, strategyTemplate=strategy, planningHorizonDays=14, startDate=start, timeLimitSeconds=time_limit, ) return get_engine(engine).solve(world, params, _next_id_factory()) def test_get_engine_cp_is_cpsat(): eng = get_engine("CP") assert isinstance(eng, CpSatEngine) assert eng.name == "CP" assert eng.supports_anytime is True def test_cp_engine_type_and_solver_meta(): world = seed_world() result = _run(world) assert result.engineType == "CP" assert world["scheduleVersions"][0]["engineType"] == "CP" meta = world["scheduleVersions"][0].get("solverMeta") or {} assert meta.get("backend") == "OR-Tools CP-SAT" assert result.solveStatus in ("OPTIMAL", "FEASIBLE", "UNKNOWN", "INFEASIBLE") assert result.solveTimeSec is not None assert result.poCount == 7 assert result.woCount == 30 def test_cp_no_workstation_overlap(): world = seed_world() _run(world) by_ws: dict[int, list] = {} 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} 双占" def test_cp_tardiness_not_worse_than_rule(): """同一输入:CP 延期不劣于 RULE(允许微小浮点差)。""" start = fmt_date(add_minutes(today0(), 24 * 60)) params_r = EngineParams( orderIds=[], engineType="RULE", strategyTemplate="COMPREHENSIVE", planningHorizonDays=14, startDate=start, ) params_c = EngineParams( orderIds=[], engineType="CP", strategyTemplate="COMPREHENSIVE", planningHorizonDays=14, startDate=start, timeLimitSeconds=10.0, ) w_r, w_c = seed_world(), seed_world() r = RuleEngine().solve(w_r, params_r, _next_id_factory()) c = CpSatEngine().solve(w_c, params_c, _next_id_factory()) assert c.totalTardiness <= r.totalTardiness + 1e-3 # 冲突数:允许 CP 因选线不同略不同,但不应显著恶化 assert c.conflictCount <= r.conflictCount + 2 def test_cp_timeout_returns_feasible_or_status(): """极短时限:必须返回带 status 的结果,不得静默变 RULE。""" world = seed_world() result = _run(world, time_limit=0.5) assert result.engineType == "CP" assert result.solveStatus is not None meta = world["scheduleVersions"][0]["solverMeta"] assert meta["backend"] == "OR-Tools CP-SAT" # anytime:通常至少 FEASIBLE/OPTIMAL/UNKNOWN;若 INFEASIBLE 则有 fallback 留痕 assert result.poCount >= 0 @pytest.mark.parametrize("requested", ["RULE", "GA"]) def test_non_cp_still_rule_proxy(requested): world = seed_world() result = _run(world, engine=requested) assert result.engineType == requested assert isinstance(get_engine(requested), RuleEngine) def test_hybrid_is_real_pipeline(): from server.engines.cp_engine import HybridEngine eng = get_engine("HYBRID") assert isinstance(eng, HybridEngine) world = seed_world() result = _run(world, engine="HYBRID") assert result.engineType == "HYBRID" meta = world["scheduleVersions"][0].get("solverMeta") or {} assert meta.get("warmStart") == "RULE" assert "RULE→CP-SAT" in str(meta.get("pipeline") or "") assert result.solveStatus is not None assert result.poCount == 7 def test_hybrid_tardiness_not_worse_than_rule(): start = fmt_date(add_minutes(today0(), 24 * 60)) w_r, w_h = seed_world(), seed_world() r = RuleEngine().solve(w_r, EngineParams( orderIds=[], engineType="RULE", strategyTemplate="COMPREHENSIVE", planningHorizonDays=14, startDate=start, ), _next_id_factory()) h = get_engine("HYBRID").solve(w_h, EngineParams( orderIds=[], engineType="HYBRID", strategyTemplate="COMPREHENSIVE", planningHorizonDays=14, startDate=start, timeLimitSeconds=10.0, ), _next_id_factory()) assert h.totalTardiness <= r.totalTardiness + 1e-3