aps-agent/tests/golden/test_mes_evidence_v2.py

285 lines
12 KiB
Python

from __future__ import annotations
import pytest
from server.aps_domain.closed_loop_runtime import run_closed_loop_candidate
from server.aps_domain.mes import validate_dispatchable_version
from tests.golden.test_closed_loop_runtime import BUSINESS_DATE, _ready_world
from tests.golden.test_flex_publish_mes_gate import _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 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_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"]