aps-agent/tests/golden/test_closed_loop_resource_a...

208 lines
7.5 KiB
Python

from __future__ import annotations
from copy import deepcopy
import pytest
from server.aps_domain.closed_loop_problem import build_closed_loop_problem
from server.aps_domain.closed_loop_runtime import (
closed_loop_to_problem_v2,
flex_version_to_solution_v2,
)
from server.aps_domain.scheduling_problem_v2 import (
ResourceKind,
SchedulingProblemV2,
SchedulingSolutionV2,
scheduling_problem_hash,
)
from server.aps_domain.scheduling_validator import validate_scheduling_solution_v2
from tests.golden.test_closed_loop_runtime import _ready_world
BUSINESS_DATE = "2026-08-02"
def _production_resource_world() -> dict:
world = _ready_world()
world.update({
"factories": [{"id": 1, "code": "F1", "name": "Factory", "status": "ACTIVE"}],
"workshops": [{"id": 1, "factoryId": 1, "code": "W1", "name": "Workshop", "status": "ACTIVE"}],
"lines": [{
"id": 1,
"workshopId": 1,
"code": "L1",
"name": "Line",
"status": "ACTIVE",
"parallelCapacity": 2,
"capacityPerDay": 1_000,
}],
"workstations": [{
"id": 1,
"lineId": 1,
"code": "S1",
"name": "Workstation",
"status": "ACTIVE",
"parallelCapacity": 2,
}],
"flexTeams": [{
"code": "T-CUT",
"name": "Cutting team",
"memberCount": 2,
"supportOps": ["CUT"],
"workstationId": 1,
}],
"flexMolds": [{
"id": 1,
"code": "M-CUT",
"name": "Cutting mold",
"operationCode": "CUT",
"adaptableEquipment": ["EQ-CUT"],
"lifeTotal": 10,
"lifeUsed": 2,
"status": "AVAILABLE",
"workstationId": 1,
}],
})
world["flexEquipment"][0].update({
"workstationId": 1,
"adaptableMolds": ["M-CUT"],
})
world["routingSteps"][0].update({
"requireMold": True,
"requiredTeamMembers": 1,
"moldLifePerUnit": 1,
})
return world
def _problem_and_solution(*, include_team: bool = True, include_tooling: bool = True):
world = _production_resource_world()
closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE)
problem = closed_loop_to_problem_v2(world, closed_loop)
requirement_id = closed_loop.manufacturing_demands[0].requirement_id
world["flexOrders"] = [{
"id": 1,
"orderNo": "SO-READY",
"closedLoopRequirementId": requirement_id,
}]
world["flexScheduleVersions"] = [{
"id": 1,
"createdAt": f"{BUSINESS_DATE} 00:00",
"totalTardiness": 0,
"avgUtilization": 0.5,
}]
world["flexWorkOrders"] = [{
"id": 1,
"versionId": 1,
"flexOrderNo": "SO-READY",
"closedLoopRequirementId": requirement_id,
"seq": 1,
"operationCode": "CUT",
"equipmentId": 1,
"equipmentCode": "EQ-CUT",
"teamCode": "T-CUT" if include_team else None,
"moldCode": "M-CUT" if include_tooling else None,
"plannedStartTime": "2026-08-03 08:00",
"plannedEndTime": "2026-08-03 08:10",
}]
world["flexConflicts"] = []
solution = flex_version_to_solution_v2(world, closed_loop, problem, 1)
return world, problem, solution
def _rebind(problem: SchedulingProblemV2, solution: SchedulingSolutionV2) -> SchedulingSolutionV2:
provenance = solution.provenance.model_copy(update={"problemHash": scheduling_problem_hash(problem)})
return solution.model_copy(update={"provenance": provenance})
def _replace_resource(problem: SchedulingProblemV2, resource_id: str, **updates) -> SchedulingProblemV2:
resources = tuple(
resource.model_copy(update=updates) if resource.resourceId == resource_id else resource
for resource in problem.resources
)
return problem.model_copy(update={"resources": resources})
def test_production_adapter_reads_organization_team_and_tooling_resources():
world = _production_resource_world()
before = deepcopy(world)
closed_loop = build_closed_loop_problem(world, business_date=BUSINESS_DATE)
problem = closed_loop_to_problem_v2(world, closed_loop)
assert world == before
resources = {resource.resourceId: resource for resource in problem.resources}
assert resources["workshop:1"].parentResourceId == "factory:1"
assert resources["line:1"].parentResourceId == "workshop:1"
assert resources["workstation:1"].parentResourceId == "line:1"
assert resources["equipment:1"].parentResourceId == "workstation:1"
assert resources["team:T-CUT"].kind is ResourceKind.TEAM
assert resources["team:T-CUT"].cumulativeCapacity == 2
assert resources["tooling:M-CUT"].kind is ResourceKind.TOOLING
assert resources["tooling:M-CUT"].compatibleResourceIds == ("equipment:1",)
assert resources["tooling:M-CUT"].lifeTotal == 10
requirements = {item.kind: item for item in problem.activities[0].resourceRequirements}
assert set(requirements) == {ResourceKind.EQUIPMENT, ResourceKind.TEAM, ResourceKind.TOOLING}
assert requirements[ResourceKind.TEAM].eligibleResourceIds == ("team:T-CUT",)
assert requirements[ResourceKind.TOOLING].eligibleResourceIds == ("tooling:M-CUT",)
assert requirements[ResourceKind.TOOLING].lifeUnits == 1
def test_complete_multi_resource_candidate_passes_v2_validator():
world, problem, solution = _problem_and_solution()
report = validate_scheduling_solution_v2(problem, solution, world=world)
assert report.valid is True
assert report.hardViolations == ()
assert {allocation.kind for allocation in solution.activities[0].resourceAllocations} == {
ResourceKind.EQUIPMENT,
ResourceKind.TEAM,
ResourceKind.TOOLING,
}
def test_external_style_candidate_missing_team_and_tooling_fails_closed():
world, problem, solution = _problem_and_solution(include_team=False, include_tooling=False)
report = validate_scheduling_solution_v2(problem, solution, world=world)
codes = {issue.code for issue in report.hardViolations}
assert report.valid is False
assert "REQUIRED_RESOURCE_ASSIGNMENT_MISSING" in codes
missing_kinds = {
issue.entityRefs[-1]
for issue in report.hardViolations
if issue.code == "REQUIRED_RESOURCE_ASSIGNMENT_MISSING"
}
assert missing_kinds == {"TEAM", "TOOLING"}
@pytest.mark.parametrize(
("resource_id", "expected_ref"),
[("team:T-CUT", "team:T-CUT"), ("line:1", "line:1")],
)
def test_team_and_hierarchy_cumulative_capacity_fail_closed(resource_id: str, expected_ref: str):
world, problem, solution = _problem_and_solution()
constrained = _replace_resource(problem, resource_id, cumulativeCapacity=0.5)
report = validate_scheduling_solution_v2(constrained, _rebind(constrained, solution), world=world)
assert report.valid is False
assert any(
issue.code == "RESOURCE_CAPACITY_OVERLAP" and expected_ref in issue.entityRefs
for issue in report.hardViolations
)
def test_tooling_life_exhaustion_fails_closed():
world, problem, solution = _problem_and_solution()
exhausted = _replace_resource(problem, "tooling:M-CUT", lifeTotal=2, lifeUsed=2)
report = validate_scheduling_solution_v2(exhausted, _rebind(exhausted, solution), world=world)
assert report.valid is False
assert any(
issue.code == "TOOLING_LIFE_EXCEEDED" and "tooling:M-CUT" in issue.entityRefs
for issue in report.hardViolations
)