aps-agent/tests/golden/test_rolling_reschedule.py

146 lines
6.3 KiB
Python
Raw Normal View History

# ============================================================
# SC-12 滚动窗 / DY-01 分级重排 / SC-11 班组约束
# ============================================================
from __future__ import annotations
from server.agent_core.intent import parse_fast
from server.aps_domain.flex import reschedule_by_level, run_flex_schedule
from server.engines.pool_engine import PoolEngine, parse_duration, resolve_window_minutes
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, today0
class _MemStore:
def __init__(self, data):
self.data = data
def next_id(self, kind: str) -> int:
key = f"_c_{kind}"
self.data[key] = self.data.get(key, 1000) + 1
return self.data[key]
def save(self):
pass
def test_parse_duration_and_window():
assert parse_duration("2h") == 120
assert parse_duration("2d") == 2 * 24 * 60
assert parse_duration("60m") == 60
assert resolve_window_minutes({"rollingWindows": {"short": "2h"}}, "short") == 120
assert resolve_window_minutes({}, "full") is None
assert resolve_window_minutes({}, "realtime") == 60
from server.engines.pool_engine import window_step_minutes
assert window_step_minutes("realtime") == 1
assert window_step_minutes("short") == 15
def test_short_window_defers_or_truncates():
world = seed_world()
start = fmt_date(add_minutes(today0(), 24 * 60))
r = PoolEngine().solve(world, _MemStore(world).next_id, sort_mode="BOTTLENECK",
start_date=start, window="short")
# 短窗 2h 不可能排完全部 10 单全工序 → 应有延期或截断冲突,或 wo 明显少于全量
full = seed_world()
r_full = PoolEngine().solve(full, _MemStore(full).next_id, sort_mode="BOTTLENECK",
start_date=start, window="full")
assert r["woCount"] <= r_full["woCount"]
assert r.get("window") == "short"
assert r["stepMinutes"] == 15
def test_team_constraint_assigns_team_code():
world = seed_world()
start = fmt_date(add_minutes(today0(), 24 * 60))
PoolEngine().solve(world, _MemStore(world).next_id, sort_mode="BOTTLENECK",
start_date=start, enforce_teams=True)
wos = world["flexWorkOrders"]
assert wos
assert any(w.get("teamCode") for w in wos)
def test_l2_reschedule_freezes_outside():
store = _MemStore(seed_world())
run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test", window="full")
before_wo = len([w for w in store.data["flexWorkOrders"]
if w["versionId"] == store.data["flexScheduleVersions"][-1]["id"]])
assert before_wo > 0
r = reschedule_by_level(store, level="L2", actor="test")
assert r["level"] == "L2"
assert r["schedule"]["versionNo"]
# 新版本含冻结标记工单(若有窗外)或至少产出版本
latest = store.data["flexScheduleVersions"][-1]["id"]
frozen = [w for w in store.data["flexWorkOrders"]
if w["versionId"] == latest and w.get("frozen")]
assert r.get("frozenWo", 0) == len(frozen) or r["schedule"]["woCount"] >= 0
def test_intent_rolling_and_reschedule():
world = seed_world()
assert parse_fast("短窗重排", world).intent == "flex.reschedule"
assert parse_fast("短窗重排", world).params.get("level") == "L2"
assert parse_fast("日窗重排", world).params.get("level") == "L3"
hit = parse_fast("滚动排产", world)
assert hit.intent == "flex.schedule"
assert hit.params.get("window") == "short"
rt = parse_fast("实时排产", world)
assert rt.intent == "flex.schedule"
assert rt.params.get("window") == "realtime"
def test_realtime_window_step_one_minute():
world = seed_world()
start = fmt_date(add_minutes(today0(), 24 * 60))
r = PoolEngine().solve(world, _MemStore(world).next_id, sort_mode="BOTTLENECK",
start_date=start, window="realtime")
assert r.get("window") == "realtime"
assert r["stepMinutes"] == 1
assert r.get("windowMinutes") == 60
def test_l1_local_swap_keeps_others():
"""L1:PRESS-01 工单换备机;他单设备与时段不变;不新建版本。"""
from server.aps_domain.flex import local_swap_equipment
store = _MemStore(seed_world())
run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test", window="full")
latest = store.data["flexScheduleVersions"][-1]["id"]
before_ver_n = len(store.data["flexScheduleVersions"])
wos = [w for w in store.data["flexWorkOrders"] if w["versionId"] == latest]
press_wos = [w for w in wos if w.get("equipmentCode") == "PRESS-01"]
assert press_wos, "种子排产后应有 PRESS-01 工单"
snapshot = {
w["id"]: (w["equipmentCode"], w["plannedStartTime"], w["plannedEndTime"])
for w in wos if w["id"] not in {x["id"] for x in press_wos}
}
r = local_swap_equipment(store, equipment_code="PRESS-01", mark_fault=True, actor="test")
assert r["swapped"]
assert all(s["from"] == "PRESS-01" and s["to"] != "PRESS-01" for s in r["swapped"])
assert r.get("faulted") == "PRESS-01"
assert len(store.data["flexScheduleVersions"]) == before_ver_n
for wid, (code, start, end) in snapshot.items():
wo = next(w for w in store.data["flexWorkOrders"] if w["id"] == wid)
assert wo["equipmentCode"] == code
assert wo["plannedStartTime"] == start
assert wo["plannedEndTime"] == end
def test_reschedule_is_p2_and_preview():
from server.agent_core import harness
from server.aps_domain.flex import preview_reschedule
assert harness.power_of("flex.reschedule") == "P2"
assert harness.power_of("flex.swap") == "P1"
store = _MemStore(seed_world())
run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test")
title, lines = preview_reschedule(store, "L2")
assert "L2" in title
assert lines
# stage 不出执行:只登记确认卡
block = harness.stage_confirmation("test", "flex.reschedule", {"level": "L2"},
title=title, summary_lines=lines)
assert block.props["confirmId"]
assert harness.power_of("flex.reschedule") == "P2"
pending = harness.take_confirmation(block.props["confirmId"])
assert pending["action"] == "flex.reschedule"
# 取出后世界版本数不变(未执行)
assert len(store.data["flexScheduleVersions"]) == 1