2026-07-28 02:12:46 +08:00
|
|
|
# ============================================================
|
|
|
|
|
# 外部算法排产 Skill 黄金测试
|
|
|
|
|
# ============================================================
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-20 11:39:21 +08:00
|
|
|
import pytest
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
from server.agent_core.skills import SkillRegistry
|
|
|
|
|
from server.aps_domain.scheduling_dto import (
|
|
|
|
|
apply_flex_solution, world_to_flex_problem,
|
|
|
|
|
)
|
|
|
|
|
from server.engines import get_engine
|
|
|
|
|
from server.engines.external_engine import ExternalEngine, run_external_flex
|
|
|
|
|
from server.integrations.algo_skill_stub import solve_problem
|
|
|
|
|
from server.state.seed import ensure_flex_seed, seed_world
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _MemStore:
|
|
|
|
|
def __init__(self, data):
|
|
|
|
|
self.data = data
|
|
|
|
|
self._c = {}
|
|
|
|
|
|
|
|
|
|
def next_id(self, kind: str) -> int:
|
|
|
|
|
self._c[kind] = self._c.get(kind, 0) + 1
|
|
|
|
|
return self._c[kind]
|
|
|
|
|
|
|
|
|
|
def save(self) -> None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_stub_solves_flex_problem():
|
|
|
|
|
world = seed_world()
|
|
|
|
|
ensure_flex_seed(world)
|
|
|
|
|
problem = world_to_flex_problem(world)
|
|
|
|
|
assert problem.orders
|
|
|
|
|
assert problem.routings
|
|
|
|
|
assert problem.resources
|
|
|
|
|
sol = solve_problem(problem)
|
|
|
|
|
assert sol.status in ("FEASIBLE", "OPTIMAL")
|
|
|
|
|
assert sol.operations
|
|
|
|
|
assert sol.runId
|
|
|
|
|
# 写回
|
|
|
|
|
counters = {}
|
|
|
|
|
|
|
|
|
|
def nid(k):
|
|
|
|
|
counters[k] = counters.get(k, 0) + 1
|
|
|
|
|
return counters[k]
|
|
|
|
|
|
|
|
|
|
summary = apply_flex_solution(world, sol, nid, skill_id="algo.stub")
|
|
|
|
|
assert summary["woCount"] >= 1
|
|
|
|
|
assert summary["skillId"] == "algo.stub"
|
|
|
|
|
assert any(v.get("engineType") == "EXTERNAL" for v in world["flexScheduleVersions"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_external_engine_local_stub(tmp_path, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("APS_SKILLS_PATH", str(tmp_path / "skills.json"))
|
|
|
|
|
# 重置单例
|
|
|
|
|
import server.agent_core.skills as sk
|
|
|
|
|
sk._registry = None
|
|
|
|
|
world = seed_world()
|
|
|
|
|
ensure_flex_seed(world)
|
|
|
|
|
eng = get_engine("EXTERNAL:algo.stub")
|
|
|
|
|
assert isinstance(eng, ExternalEngine)
|
|
|
|
|
counters = {}
|
|
|
|
|
|
|
|
|
|
def nid(k):
|
|
|
|
|
counters[k] = counters.get(k, 0) + 1
|
|
|
|
|
return counters[k]
|
|
|
|
|
|
|
|
|
|
from server.engines.base import EngineParams
|
|
|
|
|
from server.timeutil import add_minutes, fmt_date, today0
|
|
|
|
|
params = EngineParams(
|
|
|
|
|
orderIds=[], engineType="EXTERNAL", strategyTemplate="EXTERNAL",
|
|
|
|
|
planningHorizonDays=14, startDate=fmt_date(add_minutes(today0(), 24 * 60)),
|
|
|
|
|
)
|
|
|
|
|
world["_externalSkillId"] = "algo.stub"
|
|
|
|
|
result = eng.solve(world, params, nid)
|
|
|
|
|
assert result.engineType == "EXTERNAL"
|
|
|
|
|
assert result.woCount >= 1
|
|
|
|
|
assert any(r.startswith("skill:") for r in result.evidenceRefs)
|
|
|
|
|
assert any(r.startswith("run:") for r in result.evidenceRefs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_external_flex_and_registry_health(tmp_path, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("APS_SKILLS_PATH", str(tmp_path / "skills.json"))
|
|
|
|
|
import server.agent_core.skills as sk
|
|
|
|
|
sk._registry = None
|
|
|
|
|
reg = SkillRegistry(path=str(tmp_path / "skills.json"))
|
|
|
|
|
health = reg.health("algo.stub")
|
|
|
|
|
assert health and health[0]["ok"] is True
|
|
|
|
|
|
|
|
|
|
world = seed_world()
|
|
|
|
|
ensure_flex_seed(world)
|
|
|
|
|
store = _MemStore(world)
|
|
|
|
|
summary = run_external_flex(store, skill_id="algo.stub")
|
|
|
|
|
assert summary["woCount"] >= 1
|
|
|
|
|
assert summary["runId"]
|
2026-08-20 11:39:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_real_world_external_skill_is_v2_validated_before_materialization(tmp_path, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("APS_SKILLS_PATH", str(tmp_path / "skills-v2.json"))
|
|
|
|
|
import server.agent_core.skills as sk
|
|
|
|
|
from tests.golden.test_closed_loop_runtime import _ready_world
|
|
|
|
|
|
|
|
|
|
sk._registry = None
|
|
|
|
|
world = _ready_world()
|
|
|
|
|
world["businessDate"] = "2026-08-03"
|
|
|
|
|
world["scheduleVersions"] = []
|
|
|
|
|
store = _MemStore(world)
|
|
|
|
|
|
|
|
|
|
summary = run_external_flex(store, skill_id="algo.stub")
|
|
|
|
|
|
|
|
|
|
assert summary["executionMode"] == "CLOSED_LOOP_V2_VALIDATED"
|
|
|
|
|
assert summary["solveStatus"] == "FEASIBLE"
|
|
|
|
|
assert summary["validation"]["valid"] is True
|
|
|
|
|
assert summary["woCount"] == 1
|
|
|
|
|
version = next(
|
|
|
|
|
row for row in world["flexScheduleVersions"]
|
|
|
|
|
if row["id"] == summary["versionId"]
|
|
|
|
|
)
|
|
|
|
|
assert version["validationReport"]["hardViolations"] == []
|
|
|
|
|
assert version["schedulingSolutionV2"]["problemId"] == summary["planningProblemId"]
|
|
|
|
|
assert world["flexWorkOrders"][0]["activityId"].startswith("ACT:")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_real_world_external_skill_invalid_resource_fails_closed(tmp_path, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("APS_SKILLS_PATH", str(tmp_path / "skills-invalid.json"))
|
|
|
|
|
import server.agent_core.skills as sk
|
|
|
|
|
from tests.golden.test_closed_loop_runtime import _ready_world
|
|
|
|
|
|
|
|
|
|
sk._registry = None
|
|
|
|
|
world = _ready_world()
|
|
|
|
|
world["businessDate"] = "2026-08-03"
|
|
|
|
|
world["scheduleVersions"] = []
|
|
|
|
|
store = _MemStore(world)
|
|
|
|
|
|
|
|
|
|
def invalid_solution(self, skill, problem):
|
|
|
|
|
solution = solve_problem(problem)
|
|
|
|
|
operations = tuple(
|
|
|
|
|
op.model_copy(update={"resourceCode": "EQ-NOT-ALLOWED"})
|
|
|
|
|
for op in solution.operations
|
|
|
|
|
)
|
|
|
|
|
return solution.model_copy(update={"operations": operations})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(ExternalEngine, "_call_skill", invalid_solution)
|
|
|
|
|
with pytest.raises(ValueError, match="SchedulingProblemV2 validator"):
|
|
|
|
|
run_external_flex(store, skill_id="algo.stub")
|
|
|
|
|
|
|
|
|
|
assert world.get("flexScheduleVersions") in (None, [])
|
|
|
|
|
assert world.get("flexVirtualLines") in (None, [])
|
|
|
|
|
assert world.get("flexWorkOrders") in (None, [])
|
|
|
|
|
assert world["closedLoopProblems"]
|
|
|
|
|
|
|
|
|
|
def test_external_skill_version_metadata_uses_closed_loop_business_date(tmp_path, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("APS_SKILLS_PATH", str(tmp_path / "skills-business-date.json"))
|
|
|
|
|
import server.agent_core.skills as sk
|
|
|
|
|
from tests.golden.test_closed_loop_runtime import _ready_world
|
|
|
|
|
|
|
|
|
|
sk._registry = None
|
|
|
|
|
world = _ready_world()
|
|
|
|
|
world["businessDate"] = "2026-08-02"
|
|
|
|
|
world["scheduleVersions"] = []
|
|
|
|
|
store = _MemStore(world)
|
|
|
|
|
|
|
|
|
|
summary = run_external_flex(store, skill_id="algo.stub")
|
|
|
|
|
version = next(
|
|
|
|
|
row for row in world["flexScheduleVersions"]
|
|
|
|
|
if row["id"] == summary["versionId"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert summary["versionNo"].startswith("EXT20260802-")
|
|
|
|
|
assert version["versionNo"] == summary["versionNo"]
|
|
|
|
|
assert version["createdAt"] == "2026-08-02 00:00"
|