aps-agent/tests/golden/test_mes_evidence_v2.py

348 lines
15 KiB
Python

from __future__ import annotations
from pathlib import Path
import pytest
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
from server.aps_domain.mes import preview_dispatch, validate_dispatchable_version
from server.aps_domain.workflow import execute_confirmed, stage_schedule_publish
from server.engines.cp_engine import CpSatEngine
from tests.golden.test_closed_loop_runtime import BUSINESS_DATE, _ready_world
from tests.golden.test_cp_c3_calendar import _counter, _params, _single_line_world
from tests.golden.test_flex_publish_mes_gate import _MemStore, _scheduled_world
def _scheduled_v2_world() -> tuple[dict, int]:
world = _ready_world()
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
result = run_closed_loop_candidate(world, next_id, business_date=BUSINESS_DATE)
assert result["solveStatus"] == "FEASIBLE"
return world, int(result["versionId"])
def _scheduled_multi_resource_v2_world() -> tuple[dict, int]:
world = _ready_world()
world.update({
"factories": [{
"id": 101, "code": "F1", "name": "Factory", "status": "ACTIVE",
"capacityMinutesPerDay": 1000,
}],
"workshops": [{
"id": 201, "code": "WS1", "name": "Workshop", "factoryId": 101,
"status": "ACTIVE", "capacityMinutesPerDay": 900,
}],
"lines": [{
"id": 301, "code": "L1", "name": "Line", "workshopId": 201,
"status": "ACTIVE", "capacityMinutesPerDay": 800,
}],
"workstations": [{
"id": 401, "code": "ST1", "name": "Station", "lineId": 301,
"status": "ACTIVE", "capacityMinutesPerDay": 700,
}],
"flexTeams": [{
"id": 501, "code": "TEAM-CUT", "name": "Cut team", "status": "ACTIVE",
"memberCount": 2, "supportOps": ["CUT"], "workstationId": 401,
}],
"flexMolds": [{
"id": 601, "code": "MOLD-CUT", "name": "Cut mold", "status": "AVAILABLE",
"quantity": 1, "operationCode": "CUT", "capabilities": ["CUT"],
"adaptableEquipment": ["EQ-CUT"], "lifeTotal": 100, "lifeUsed": 2,
"workstationId": 401,
}],
})
world["flexEquipment"][0]["workstationId"] = 401
world["routingSteps"][0].update({
"requireMold": True,
"requiredTeamMembers": 1,
"moldLifePerUnit": 2,
})
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
result = run_closed_loop_candidate(world, next_id, business_date=BUSINESS_DATE)
assert result["solveStatus"] == "FEASIBLE"
return world, int(result["versionId"])
def _codes(validation: dict) -> set[str]:
return {str(item["code"]) for item in validation["publishBlockingReasons"]}
def _scheduled_fixed_segmented_world() -> tuple[dict, int]:
world, start_date = _single_line_world(quantity=500, day_shift_only=True)
result = CpSatEngine().solve(world, _params(start_date), _counter())
assert result.solveStatus in {"OPTIMAL", "FEASIBLE"}
version = next(row for row in world["scheduleVersions"] if row["id"] == result.versionId)
version["status"] = "PUBLISHED"
return world, int(result.versionId)
def test_mes_evidence_legacy_compatibility_is_limited_to_explicit_demo_world():
world, version_id = _scheduled_world()
demo_gate = validate_dispatchable_version(world, "flex", version_id)
assert demo_gate["publishReady"] is True
assert demo_gate["evidenceSummary"]["mode"] == "LEGACY_DEMO_COMPAT"
world["factories"][0]["name"] = "????"
real_gate = validate_dispatchable_version(world, "flex", version_id)
assert real_gate["publishReady"] is False
assert real_gate["dispatchReady"] is False
assert "V2_EVIDENCE_REQUIRED" in _codes(real_gate)
assert real_gate["evidenceRef"] != demo_gate["evidenceRef"]
def test_fixed_cp_segments_are_in_mes_evidence_and_drift_fails_closed():
world, version_id = _scheduled_fixed_segmented_world()
baseline = validate_dispatchable_version(world, "fixed", version_id)
preview = preview_dispatch(world, "fixed", version_id)
assert baseline["publishReady"] is True
assert baseline["dispatchReady"] is True
assert preview["dispatchReady"] is True
assert preview["items"][0]["segments"] == world["workOrders"][0]["plannedSegments"]
assert preview["items"][0]["processingMinutes"] == 280
assert preview["items"][0]["elapsedSpanMinutes"] == 340
assert preview["items"][0]["pauseMinutes"] == 60
world["workOrders"][0]["plannedSegments"][0]["durationMin"] += 1
drifted = validate_dispatchable_version(world, "fixed", version_id)
assert drifted["publishReady"] is False
assert drifted["dispatchReady"] is False
assert "INVALID_PLANNED_SEGMENTS" in _codes(drifted)
assert drifted["evidenceRef"] != baseline["evidenceRef"]
assert preview_dispatch(world, "fixed", version_id)["items"] == []
def test_fixed_cp_publish_revalidates_staged_evidence_and_rejects_segment_drift(
tmp_path: Path,
):
world, version_id = _scheduled_fixed_segmented_world()
version = next(row for row in world["scheduleVersions"] if row["id"] == version_id)
version["status"] = "DRAFT"
store = _MemStore(world, tmp_path / "fixed-publish-checkpoints.json")
reply = stage_schedule_publish(
store,
session_id="fixed-publish-drift",
actor="planner",
track="fixed",
version_id=version_id,
)
confirm_id = str(
next(block for block in reply.blocks if block.type == "confirm-card").props["confirmId"]
)
world["workOrders"][0]["plannedSegments"][0]["durationMin"] += 1
text = execute_confirmed(store, confirm_id, True, actor="planner")
assert "结构校验" in text or "漂移" in text
assert version["status"] == "DRAFT"
def test_mes_evidence_v2_digest_is_stable_and_binds_planning_solution_validation_resources():
world, version_id = _scheduled_v2_world()
first = validate_dispatchable_version(world, "flex", version_id)
second = validate_dispatchable_version(world, "flex", version_id)
assert first["publishReady"] is True
assert first["dispatchReady"] is False # DRAFT 尚未完成 P2 发布
assert first["evidenceRef"] == second["evidenceRef"]
assert first["evidenceSummary"] == second["evidenceSummary"]
summary = first["evidenceSummary"]
version = world["flexScheduleVersions"][-1]
assert summary["mode"] == "V2_FAIL_CLOSED"
assert summary["planningProblemId"] == version["planningProblemId"]
assert summary["planningSourceHash"] == version["planningSourceHash"]
assert summary["solveStatus"] == "FEASIBLE"
for key in (
"problemDigest",
"solutionDigest",
"validationDigest",
"workOrderIdentityDigest",
"plannedResourceConstraintDigest",
"currentResourceConstraintDigest",
"currentValidationDigest",
"currentOperationIdentityHash",
):
assert len(summary[key]) == 64
assert summary["plannedResourceConstraintDigest"] == summary["currentResourceConstraintDigest"]
@pytest.mark.parametrize(
("field", "replacement", "expected_code"),
[
("activityId", "ACT:TAMPERED:999", "WORK_ORDER_ACTIVITY_NOT_IN_SOLUTION"),
("activityIdentity", "0" * 64, "WORK_ORDER_ACTIVITY_IDENTITY_DRIFT"),
("closedLoopRequirementId", "REQ-TAMPERED", "WORK_ORDER_REQUIREMENT_ID_DRIFT"),
],
)
def test_mes_evidence_v2_work_order_identity_tampering_fails_closed_and_changes_evidence(
field: str,
replacement: str,
expected_code: str,
):
world, version_id = _scheduled_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexWorkOrders"][0][field] = replacement
drifted = validate_dispatchable_version(world, "flex", version_id)
assert drifted["publishReady"] is False
assert drifted["dispatchReady"] is False
assert expected_code in _codes(drifted)
assert drifted["evidenceRef"] != baseline["evidenceRef"]
assert drifted["evidenceSummary"]["workOrderIdentityDigest"] != baseline["evidenceSummary"]["workOrderIdentityDigest"]
@pytest.mark.parametrize("field", ["activityId", "activityIdentity", "closedLoopRequirementId"])
def test_mes_evidence_v2_missing_work_order_identity_field_fails_closed(field: str):
world, version_id = _scheduled_v2_world()
world["flexWorkOrders"][0][field] = None
validation = validate_dispatchable_version(world, "flex", version_id)
assert validation["publishReady"] is False
assert validation["dispatchReady"] is False
assert "INCOMPLETE_V2_WORK_ORDER_IDENTITY" in _codes(validation)
def test_mes_evidence_v2_conflicting_maintenance_fails_closed_and_changes_evidence():
world, version_id = _scheduled_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
activity = world["flexScheduleVersions"][-1]["schedulingSolutionV2"]["activities"][0]
equipment = world["flexEquipment"][0]
world["maintenance"].append({
"id": 9001,
"equipmentId": equipment["id"],
"equipmentCode": equipment["code"],
"startTime": activity["start"],
"endTime": activity["end"],
"status": "PLANNED",
})
drifted = validate_dispatchable_version(world, "flex", version_id)
assert drifted["publishReady"] is False
assert drifted["dispatchReady"] is False
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(drifted)
assert "CURRENT_RESOURCE_MAINTENANCE_VIOLATION" in _codes(drifted)
assert drifted["evidenceRef"] != baseline["evidenceRef"]
assert drifted["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
def test_mes_evidence_v2_calendar_drift_fails_closed_and_changes_evidence():
world, version_id = _scheduled_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexCalendar"][0]["startTime"] = "09:00"
drifted = validate_dispatchable_version(world, "flex", version_id)
assert drifted["publishReady"] is False
assert drifted["dispatchReady"] is False
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(drifted)
assert "CURRENT_RESOURCE_CALENDAR_VIOLATION" in _codes(drifted)
assert drifted["evidenceRef"] != baseline["evidenceRef"]
assert drifted["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
def test_mes_evidence_v2_equipment_capability_drift_fails_closed_and_changes_evidence():
world, version_id = _scheduled_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexEquipment"][0]["capabilities"] = []
drifted = validate_dispatchable_version(world, "flex", version_id)
assert drifted["publishReady"] is False
assert drifted["dispatchReady"] is False
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(drifted)
assert "CURRENT_RESOURCE_CAPABILITY_MISMATCH" in _codes(drifted)
assert drifted["evidenceRef"] != baseline["evidenceRef"]
assert drifted["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
def test_mes_evidence_v2_normalizes_complete_multi_resource_solution_and_snapshot():
world, version_id = _scheduled_multi_resource_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
assert baseline["publishReady"] is True
assert baseline["evidenceSummary"]["currentResourceKinds"] == [
"EQUIPMENT", "FACTORY", "LINE", "TEAM", "TOOLING", "WORKSHOP", "WORKSTATION",
]
allocations = world["flexScheduleVersions"][-1]["schedulingSolutionV2"]["activities"][0]["resourceAllocations"]
assert {item["kind"] for item in allocations} == {"EQUIPMENT", "TEAM", "TOOLING"}
allocations.reverse()
reordered = validate_dispatchable_version(world, "flex", version_id)
assert reordered["publishReady"] is True
assert reordered["evidenceSummary"]["solutionDigest"] == baseline["evidenceSummary"]["solutionDigest"]
assert reordered["evidenceSummary"]["solutionActivityDigest"] == baseline["evidenceSummary"]["solutionActivityDigest"]
assert reordered["evidenceRef"] == baseline["evidenceRef"]
def test_mes_evidence_v2_observed_snapshot_automatically_covers_future_resource_fields():
world, version_id = _scheduled_multi_resource_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexTeams"][0]["futureDispatchConstraint"] = {"mode": "STRICT", "limit": 7}
changed = validate_dispatchable_version(world, "flex", version_id)
assert changed["publishReady"] is True # Unknown field is evidenced, not guessed as a constraint.
assert changed["evidenceSummary"]["currentResourceSnapshotDigest"] != baseline["evidenceSummary"]["currentResourceSnapshotDigest"]
assert changed["evidenceRef"] != baseline["evidenceRef"]
@pytest.mark.parametrize(
("field", "replacement"),
[("teamCode", "TEAM-OTHER"), ("moldCode", "MOLD-OTHER")],
)
def test_mes_evidence_v2_work_order_binds_every_resource_allocation(field: str, replacement: str):
world, version_id = _scheduled_multi_resource_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexWorkOrders"][0][field] = replacement
changed = validate_dispatchable_version(world, "flex", version_id)
assert changed["publishReady"] is False
assert "WORK_ORDER_RESOURCE_ALLOCATION_DRIFT" in _codes(changed)
assert changed["evidenceRef"] != baseline["evidenceRef"]
def test_mes_evidence_v2_team_tooling_and_hierarchy_drift_change_normalized_snapshot():
world, version_id = _scheduled_multi_resource_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexTeams"][0]["memberCount"] = 1
team_drift = validate_dispatchable_version(world, "flex", version_id)
assert team_drift["publishReady"] is False
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(team_drift)
assert team_drift["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
world, version_id = _scheduled_multi_resource_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["flexMolds"][0]["lifeUsed"] += 1
tooling_drift = validate_dispatchable_version(world, "flex", version_id)
assert tooling_drift["publishReady"] is False
assert "CURRENT_RESOURCE_CONSTRAINT_DRIFT" in _codes(tooling_drift)
assert tooling_drift["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]
world, version_id = _scheduled_multi_resource_v2_world()
baseline = validate_dispatchable_version(world, "flex", version_id)
world["lines"][0]["capacityMinutesPerDay"] = 5
hierarchy_drift = validate_dispatchable_version(world, "flex", version_id)
assert hierarchy_drift["publishReady"] is False
assert "CURRENT_RESOURCE_DAILY_CAPACITY_EXCEEDED" in _codes(hierarchy_drift)
assert hierarchy_drift["evidenceSummary"]["currentResourceConstraintDigest"] != baseline["evidenceSummary"]["currentResourceConstraintDigest"]