175 lines
6.9 KiB
Python
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
|