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