132 lines
5.7 KiB
Python
132 lines
5.7 KiB
Python
# ============================================================
|
||
# SC-12 滚动窗 / DY-01 分级重排 / SC-11 班组约束
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
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_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
|