# ============================================================ # 外部算法排产 Skill 黄金测试 # ============================================================ from __future__ import annotations import pytest 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"] 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"