338 lines
12 KiB
Python
338 lines
12 KiB
Python
from typing import Any
|
|
|
|
import numpy
|
|
import pytest
|
|
from numpy import pi
|
|
|
|
from masque.builder import (
|
|
AutoTool,
|
|
BendOffer,
|
|
PathTool,
|
|
PrimitiveKind,
|
|
PrimitiveOffer,
|
|
RenderStep,
|
|
SOffer,
|
|
StraightOffer,
|
|
Tool,
|
|
ToolContractCase,
|
|
ToolContractError,
|
|
UOffer,
|
|
validate_tool_contract,
|
|
)
|
|
from masque.error import BuildError
|
|
from masque.library import ILibrary, Library, SINGLE_USE_PREFIX
|
|
from masque.pattern import Pattern
|
|
from masque.ports import Port
|
|
|
|
|
|
def make_straight(length: float, *, ptype: str = 'wire') -> Pattern:
|
|
return Pattern(ports={
|
|
'A': Port((0, 0), 0, ptype=ptype),
|
|
'B': Port((length, 0), pi, ptype=ptype),
|
|
})
|
|
|
|
|
|
class EmptyTool(Tool):
|
|
def primitive_offers(
|
|
self,
|
|
kind: PrimitiveKind,
|
|
*,
|
|
in_ptype: str | None = None,
|
|
out_ptype: str | None = None,
|
|
**kwargs: Any,
|
|
) -> tuple[PrimitiveOffer, ...]:
|
|
_ = kind, in_ptype, out_ptype, kwargs
|
|
return ()
|
|
|
|
def render(
|
|
self,
|
|
batch: tuple[RenderStep, ...],
|
|
*,
|
|
port_names: tuple[str, str] = ('A', 'B'),
|
|
) -> ILibrary:
|
|
_ = batch
|
|
tree, pattern = Library.mktree('empty_tool')
|
|
pattern.add_port_pair(names=port_names)
|
|
return tree
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('offer', 'kind', 'opcode'),
|
|
[
|
|
(StraightOffer('wire', 'wire'), 'straight', 'L'),
|
|
(BendOffer('wire', 'wire'), 'bend', 'L'),
|
|
(SOffer('wire', 'wire'), 's', 'S'),
|
|
(UOffer('wire', 'wire'), 'u', 'U'),
|
|
],
|
|
)
|
|
def test_offer_kind_is_canonical_and_opcode_is_derived(
|
|
offer: StraightOffer | BendOffer | SOffer | UOffer,
|
|
kind: str,
|
|
opcode: str,
|
|
) -> None:
|
|
assert offer.kind == kind
|
|
assert offer.opcode == opcode
|
|
|
|
|
|
def test_render_step_stores_kind_and_derives_opcode() -> None:
|
|
tool = PathTool(layer='M1', width=1, ptype='wire')
|
|
port = Port((0, 0), 0, ptype='wire')
|
|
step = RenderStep('straight', tool, port, port, None)
|
|
plug = RenderStep('plug', None, port, port, None)
|
|
|
|
assert step.kind == 'straight'
|
|
assert step.opcode == 'L'
|
|
assert step.transformed(numpy.zeros(2), 0, numpy.zeros(2)).kind == 'straight'
|
|
assert step.mirrored(0).kind == 'straight'
|
|
assert plug.opcode == 'P'
|
|
|
|
with pytest.raises(BuildError, match='Unrecognized RenderStep kind'):
|
|
RenderStep('L', tool, port, port, None) # type: ignore[arg-type]
|
|
with pytest.raises(BuildError, match='requires tool=None'):
|
|
RenderStep('plug', tool, port, port, None)
|
|
|
|
|
|
def test_standard_offer_endpoint_callback_runs_once_per_solver_evaluation() -> None:
|
|
endpoint_calls: list[float] = []
|
|
received_endpoints: list[Port] = []
|
|
|
|
def endpoint(length: float) -> Port:
|
|
endpoint_calls.append(length)
|
|
return Port((length, 0), pi, ptype='wire')
|
|
|
|
def cost(length: float, out_port: Port) -> float:
|
|
_ = length
|
|
received_endpoints.append(out_port)
|
|
return out_port.x
|
|
|
|
class OneOfferTool(EmptyTool):
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
if kind != 'straight':
|
|
return ()
|
|
return (StraightOffer(
|
|
in_ptype='wire',
|
|
out_ptype='wire',
|
|
cost=cost,
|
|
endpoint_planner=endpoint,
|
|
commit_planner=lambda length: length,
|
|
),)
|
|
|
|
from masque.builder import Pather
|
|
|
|
pather = Pather(
|
|
Library(),
|
|
ports={'A': Port((0, 0), 0, ptype='wire')},
|
|
tools=OneOfferTool(),
|
|
render='deferred',
|
|
)
|
|
pather.straight('A', 5)
|
|
|
|
assert endpoint_calls.count(5) == 1
|
|
assert len(received_endpoints) >= 1
|
|
|
|
|
|
def test_custom_cost_at_override_remains_authoritative() -> None:
|
|
calls: list[float] = []
|
|
|
|
class CustomCostOffer(StraightOffer):
|
|
def cost_at(self, parameter: float) -> float:
|
|
calls.append(parameter)
|
|
return 0
|
|
|
|
offer = CustomCostOffer(
|
|
in_ptype='wire',
|
|
out_ptype='wire',
|
|
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wire'),
|
|
commit_planner=lambda length: length,
|
|
)
|
|
|
|
class CustomCostTool(EmptyTool):
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
return (offer,) if kind == 'straight' else ()
|
|
|
|
from masque.builder import Pather
|
|
|
|
pather = Pather(
|
|
Library(),
|
|
ports={'A': Port((0, 0), 0, ptype='wire')},
|
|
tools=CustomCostTool(),
|
|
render='deferred',
|
|
)
|
|
pather.straight('A', 5)
|
|
assert 5 in calls
|
|
|
|
|
|
def test_solver_rejects_offer_kind_mismatch() -> None:
|
|
class WrongKindTool(EmptyTool):
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
if kind != 'straight':
|
|
return ()
|
|
return (SOffer.generated(
|
|
'wire',
|
|
lambda jog: Port((1, jog), pi, ptype='wire'),
|
|
lambda jog: jog,
|
|
),)
|
|
|
|
from masque.builder import Pather
|
|
|
|
pather = Pather(
|
|
Library(),
|
|
ports={'A': Port((0, 0), 0, ptype='wire')},
|
|
tools=WrongKindTool(),
|
|
render='deferred',
|
|
)
|
|
with pytest.raises(ToolContractError, match='returned.*s.*offer'):
|
|
pather.straight('A', 5)
|
|
|
|
|
|
def test_validate_tool_contract_accepts_pathtool() -> None:
|
|
tool = PathTool(layer='M1', width=2, ptype='wire')
|
|
validate_tool_contract(tool, (
|
|
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
|
|
ToolContractCase('bend', in_ptype='wire', ccw=False, check_bbox=True),
|
|
ToolContractCase('bend', in_ptype='wire', ccw=True, check_bbox=True),
|
|
ToolContractCase('s', in_ptype='wire', check_bbox=True),
|
|
ToolContractCase('u', in_ptype='wire', require_offers=False),
|
|
))
|
|
|
|
|
|
def test_validate_tool_contract_accepts_autotool_with_explicit_probe() -> None:
|
|
tool = AutoTool().add_straight(
|
|
make_straight,
|
|
'wire',
|
|
'A',
|
|
length_range=(1, 10),
|
|
)
|
|
validate_tool_contract(tool, (
|
|
ToolContractCase('straight', in_ptype='wire', probe_parameters=(7,)),
|
|
))
|
|
|
|
|
|
def test_validate_tool_contract_empty_offer_policy() -> None:
|
|
tool = EmptyTool()
|
|
validate_tool_contract(tool, (ToolContractCase('u', require_offers=False),))
|
|
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
validate_tool_contract(tool, (ToolContractCase('u'),))
|
|
assert all(isinstance(err, ToolContractError) for err in exc_info.value.exceptions)
|
|
assert any('no offers' in str(err) for err in exc_info.value.exceptions)
|
|
|
|
|
|
def test_validate_tool_contract_rejects_unmatched_explicit_probe() -> None:
|
|
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
|
|
|
|
with pytest.raises(ExceptionGroup, match='Tool contract validation') as exc_info:
|
|
validate_tool_contract(tool, (
|
|
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
|
|
))
|
|
assert any('outside every discovered offer domain' in str(err) for err in exc_info.value.exceptions)
|
|
|
|
|
|
def test_validate_tool_contract_aggregates_independent_violations() -> None:
|
|
class BrokenTool(EmptyTool):
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
if kind != 'straight':
|
|
return ()
|
|
return (StraightOffer(
|
|
in_ptype='wire',
|
|
out_ptype='wire',
|
|
endpoint_planner=lambda length: Port((length + 1, 0), 0, ptype='wrong'),
|
|
commit_planner=lambda length: length,
|
|
),)
|
|
|
|
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
return Library()
|
|
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
validate_tool_contract(BrokenTool(), (
|
|
ToolContractCase('straight', in_ptype='wire', label='broken straight'),
|
|
))
|
|
|
|
errors = exc_info.value.exceptions
|
|
assert len(errors) > 1
|
|
assert all(isinstance(err, ToolContractError) for err in errors)
|
|
assert all('broken straight' in str(err) for err in errors)
|
|
|
|
|
|
def test_validate_tool_contract_detects_repeated_discovery_changes() -> None:
|
|
class ChangingTool(EmptyTool):
|
|
calls = 0
|
|
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
if kind != 'straight':
|
|
return ()
|
|
self.calls += 1
|
|
shift = float(self.calls - 1)
|
|
return (StraightOffer(
|
|
in_ptype='wire',
|
|
out_ptype='wire',
|
|
endpoint_planner=lambda length: Port((length + shift, 0), pi, ptype='wire'),
|
|
commit_planner=lambda length: length,
|
|
),)
|
|
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
validate_tool_contract(ChangingTool(), (
|
|
ToolContractCase('straight', in_ptype='wire'),
|
|
))
|
|
assert any('changed after repeated discovery' in str(err) for err in exc_info.value.exceptions)
|
|
|
|
|
|
def test_validate_tool_contract_checks_render_ports_and_single_use_refs() -> None:
|
|
class BrokenRenderTool(EmptyTool):
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
|
|
|
|
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
tree = Library()
|
|
pattern = Pattern(ports={port_names[0]: Port((0, 0), 0, ptype='wire')})
|
|
pattern.ref(SINGLE_USE_PREFIX + 'missing')
|
|
tree['top'] = pattern
|
|
return tree
|
|
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
validate_tool_contract(BrokenRenderTool(), (
|
|
ToolContractCase('straight', in_ptype='wire'),
|
|
))
|
|
messages = [str(err) for err in exc_info.value.exceptions]
|
|
assert any('missing single-use refs' in message for message in messages)
|
|
assert any('missing ports' in message for message in messages)
|
|
|
|
|
|
def test_validate_tool_contract_bbox_is_opt_in() -> None:
|
|
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
|
|
validate_tool_contract(tool, (ToolContractCase('straight', in_ptype='wire'),))
|
|
|
|
# AutoTool supplies bbox support, so use a minimal custom offer without it.
|
|
class NoBBoxTool(EmptyTool):
|
|
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
|
|
|
|
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
|
|
length = batch[0].data
|
|
tree = Library()
|
|
tree['top'] = Pattern(ports={
|
|
port_names[0]: Port((0, 0), 0, ptype='wire'),
|
|
port_names[1]: Port((length, 0), pi, ptype='wire'),
|
|
})
|
|
return tree
|
|
|
|
validate_tool_contract(NoBBoxTool(), (ToolContractCase('straight', in_ptype='wire'),))
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
validate_tool_contract(NoBBoxTool(), (
|
|
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
|
|
))
|
|
assert any('bbox_at()' in str(err) for err in exc_info.value.exceptions)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'kwargs',
|
|
[
|
|
{'kind': 'straight', 'ccw': True},
|
|
{'kind': 'bend'},
|
|
{'kind': 'straight', 'probe_parameters': (numpy.inf,)},
|
|
{'kind': 'straight', 'tool_options': {'ccw': True}},
|
|
],
|
|
)
|
|
def test_tool_contract_case_validates_configuration(kwargs: dict[str, Any]) -> None:
|
|
with pytest.raises(ValueError, match='ccw|requires|finite|reserved'):
|
|
ToolContractCase(**kwargs) # type: ignore[arg-type]
|