aps-agent/tests/golden/test_scenario_selection.py

175 lines
6.9 KiB
Python

from __future__ import annotations
import copy
import json
import math
from pathlib import Path
import pytest
from server.aps_domain.robustness import run_monte_carlo
from server.aps_domain.scenario import compare_scenarios
from server.aps_domain.scenario_selection import pareto_front_indices, rank_scenarios
from server.aps_domain.sensitivity import run_sensitivity, sensitivity_as_block
from server.state.seed import seed_world
ROOT = Path(__file__).resolve().parents[2]
def _card(
strategy: str,
*,
tardiness: float,
conflicts: int,
cost: float,
utilization: float,
balance: float,
changeover: float,
hard_feasible: bool = True,
) -> dict:
return {
"scenarioId": strategy.lower(),
"strategy": strategy,
"hardFeasible": hard_feasible,
"robustness": 0.5,
"kpi": {
"totalTardiness": tardiness,
"conflictCount": conflicts,
"totalCost": cost,
"avgUtilization": utilization,
"loadBalance": balance,
"totalChangeoverMin": changeover,
},
}
def test_pareto_front_filters_dominated_and_hard_infeasible_cards():
cards = [
_card("A", tardiness=1, conflicts=0, cost=10, utilization=.9, balance=.9, changeover=1),
_card("B", tardiness=2, conflicts=1, cost=20, utilization=.8, balance=.8, changeover=2),
_card("C", tardiness=.5, conflicts=0, cost=30, utilization=.95, balance=.7, changeover=3),
_card("D", tardiness=1, conflicts=0, cost=10, utilization=.9, balance=.9, changeover=1),
_card("E", tardiness=0, conflicts=0, cost=0, utilization=1, balance=1, changeover=0,
hard_feasible=False),
]
eligible = [idx for idx, card in enumerate(cards) if card["hardFeasible"]]
assert pareto_front_indices(cards, eligible_indices=eligible) == {0, 2, 3}
ranked = rank_scenarios(cards)
assert ranked[1]["selectionStatus"] == "DOMINATED"
assert ranked[4]["selectionStatus"] == "HARD_REJECTED"
assert ranked[4]["rank"] is None
def test_normalized_weighted_ranking_is_hand_checkable_and_stable():
cards = [
_card("A", tardiness=0, conflicts=0, cost=100, utilization=.5, balance=1, changeover=0),
_card("B", tardiness=10, conflicts=0, cost=0, utilization=.5, balance=1, changeover=0),
_card("C", tardiness=5, conflicts=0, cost=50, utilization=.5, balance=1, changeover=0),
]
weights = {
"totalTardiness": 3,
"totalCost": 1,
"avgUtilization": 0,
"loadBalance": 0,
"conflictCount": 0,
"totalChangeoverMin": 0,
}
first = rank_scenarios(cards, weights=weights)
second = rank_scenarios(cards, weights=weights)
assert [card["rank"] for card in first] == [1, 3, 2]
assert [card["weightedScore"] for card in first] == pytest.approx([.75, .25, .5])
assert [card["rank"] for card in first] == [card["rank"] for card in second]
assert all(math.isfinite(card["normalizedKpi"]["avgUtilization"]) for card in first)
assert all(card["normalizedKpi"]["avgUtilization"] == 1.0 for card in first)
def test_dominated_outlier_cannot_change_pareto_front_recommendation():
weights = {
"totalTardiness": 0.6,
"totalCost": 0.4,
"avgUtilization": 0,
"loadBalance": 0,
"conflictCount": 0,
"totalChangeoverMin": 0,
}
front = [
_card("A", tardiness=0, conflicts=0, cost=100, utilization=.5, balance=1, changeover=0),
_card("B", tardiness=10, conflicts=0, cost=0, utilization=.5, balance=1, changeover=0),
]
dominated = _card(
"D", tardiness=1000, conflicts=0, cost=1000, utilization=.5, balance=1, changeover=0,
)
without_outlier = rank_scenarios(front, weights=weights)
with_outlier = rank_scenarios([*front, dominated], weights=weights)
assert next(card for card in without_outlier if card["isRecommended"])["scenarioId"] == "a"
assert next(card for card in with_outlier if card["isRecommended"])["scenarioId"] == "a"
assert with_outlier[-1]["selectionStatus"] == "DOMINATED"
assert with_outlier[-1]["normalizedKpi"] == {}
def test_fixed_seed_monte_carlo_is_reproducible_and_does_not_mutate_world():
world = seed_world()
before = json.dumps(world, ensure_ascii=False, sort_keys=True)
first = run_monte_carlo(world, trials=8, seed=1234)
second = run_monte_carlo(world, trials=8, seed=1234)
different = run_monte_carlo(world, trials=8, seed=4321)
assert json.dumps(world, ensure_ascii=False, sort_keys=True) == before
assert first == second
assert first["outcomes"] != different["outcomes"]
assert first["trials"] == 8
assert 0.0 <= first["robustness"] <= 1.0
dist = first["distribution"]
assert dist["tardinessP50"] <= dist["tardinessP90"] <= dist["tardinessP95"] <= dist["tardinessMax"]
def test_scenario_compare_and_sensitivity_expose_selection_and_robustness_contracts():
world = seed_world()
# 演示世界含 PLANNED 维保会产生 C4_maintenance 硬冲突(无推荐);
# 标记 RESOLVED 模拟现场已处理维保的正常状态,消除测试顺序依赖
for m in world.get("maintenance") or []:
m["status"] = "RESOLVED"
world["constraintProfile"] = {
"profileId": "selection-test",
"constraints": {"C7_capacity": {"kind": "soft"}},
}
before = copy.deepcopy(world)
text, block = compare_scenarios(world)
assert world == before
cards = block.props["cards"]
recommendation = next(card for card in cards if card["isRecommended"])
assert block.props["recommendationId"] == recommendation["scenarioId"]
assert recommendation["isPareto"] is True
assert recommendation["rank"] == 1
assert recommendation["scenarioId"] in block.props["paretoScenarioIds"]
assert 0.0 <= recommendation["robustness"] <= 1.0
assert recommendation["label"] in text
assert all("normalizedKpi" in card and "weightedScore" in card for card in cards)
report = run_sensitivity(world)
assert report["monteCarlo"]["method"] == "fixed-seed-monte-carlo"
ui_block = sensitivity_as_block(report)
assert ui_block.props["monteCarlo"] == report["monteCarlo"]
def test_scenario_compare_does_not_recommend_when_every_card_breaks_hard_constraints():
world = seed_world()
text, block = compare_scenarios(world)
assert block.props["recommendationId"] is None
assert not any(card["isRecommended"] for card in block.props["cards"])
assert "不推荐采用" in text
def test_web_scenario_cards_obey_backend_recommendation_and_disable_hard_rejections():
source = (ROOT / "apps" / "web" / "src" / "chat" / "ChatPanel.tsx").read_text(encoding="utf-8")
assert "props.block.props.recommendationId" in source
assert "recommendationId === undefined" in source
assert "c.selectionStatus === 'HARD_REJECTED'" in source
assert "disabled={applied !== null || hardRejected}" in source
assert "cards.reduce" not in source