[library / gdsii] cleanup using new library rework
This commit is contained in:
parent
ddb7742493
commit
7b589e4f44
14 changed files with 696 additions and 391 deletions
|
|
@ -10,7 +10,7 @@ from ..file.gdsii import lazy as gdsii_lazy
|
|||
from ..error import LibraryError
|
||||
from ..pattern import Pattern
|
||||
from ..ports import Port
|
||||
from ..library import IBorrowing, IMaterializable, Library, OverlayLibrary
|
||||
from ..library import IBorrowing, IMaterializable, LazyLibrary, Library, OverlayLibrary, PortsLibraryView
|
||||
|
||||
|
||||
def _make_lazy_port_library() -> Library:
|
||||
|
|
@ -39,6 +39,43 @@ def test_gdsii_lazy_write_ignores_non_mapping_library_info() -> None:
|
|||
gdsii_lazy.write(lib, io.BytesIO())
|
||||
|
||||
|
||||
def test_gdsii_lazy_write_plain_library_matches_eager_writer() -> None:
|
||||
lib = _make_lazy_port_library()
|
||||
eager_stream = io.BytesIO()
|
||||
lazy_stream = io.BytesIO()
|
||||
|
||||
gdsii.write(lib, eager_stream, meters_per_unit=1e-9, library_name='writer-match')
|
||||
gdsii_lazy.write(
|
||||
lib,
|
||||
lazy_stream,
|
||||
meters_per_unit=1e-9,
|
||||
logical_units_per_unit=1,
|
||||
library_name='writer-match',
|
||||
)
|
||||
|
||||
assert lazy_stream.getvalue() == eager_stream.getvalue()
|
||||
|
||||
|
||||
def test_gdsii_lazy_write_materializes_transiently() -> None:
|
||||
lib = LazyLibrary()
|
||||
lib['top'] = Pattern()
|
||||
|
||||
stream = io.BytesIO()
|
||||
gdsii_lazy.write(
|
||||
lib,
|
||||
stream,
|
||||
meters_per_unit=1e-9,
|
||||
logical_units_per_unit=1,
|
||||
library_name='transient',
|
||||
)
|
||||
|
||||
assert not lib.cache
|
||||
stream.seek(0)
|
||||
roundtrip, info = gdsii.read(stream)
|
||||
assert set(roundtrip) == {'top'}
|
||||
assert info['name'] == 'transient'
|
||||
|
||||
|
||||
def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
|
|
@ -73,6 +110,17 @@ def test_gdsii_lazy_source_exposes_order_and_graph_without_materializing(tmp_pat
|
|||
assert set(lib._cache) == {'child'}
|
||||
|
||||
|
||||
def test_gdsii_lazy_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_cached_graph.gds'
|
||||
gdsii.writefile(_make_lazy_port_library(), gds_file, meters_per_unit=1e-9)
|
||||
|
||||
lib, _ = gdsii_lazy.readfile(gds_file)
|
||||
del lib['child'].refs['leaf']
|
||||
|
||||
assert lib.child_graph(dangling='ignore')['child'] == set()
|
||||
assert lib.find_refs_local('leaf') == {}
|
||||
|
||||
|
||||
def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_subtree_source.gds'
|
||||
src = _make_lazy_port_library()
|
||||
|
|
@ -87,6 +135,7 @@ def test_gdsii_lazy_subtree_stays_borrowed_and_preserves_write_metadata(tmp_path
|
|||
assert isinstance(subtree, IMaterializable)
|
||||
assert isinstance(subtree, IBorrowing)
|
||||
assert subtree.source_order() == ('leaf', 'child', 'top')
|
||||
assert not hasattr(subtree, 'library_info')
|
||||
assert not raw._cache
|
||||
|
||||
out_file = tmp_path / 'lazy_subtree_out.gds'
|
||||
|
|
@ -127,7 +176,8 @@ def test_gdsii_lazy_ports_view_keeps_raw_source_unmodified(tmp_path: Path) -> No
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||
processed = PortsLibraryView(raw, layers=[(10, 0)], max_depth=2)
|
||||
assert not hasattr(processed, 'library_info')
|
||||
|
||||
top = processed['top']
|
||||
assert set(top.ports) == {'A'}
|
||||
|
|
@ -145,7 +195,7 @@ def test_gdsii_lazy_ports_view_detaches_previously_cached_source(tmp_path: Path)
|
|||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
raw_top = raw['top']
|
||||
processed = raw.with_port_overrides({
|
||||
processed = PortsLibraryView(raw, ports={
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
|
|
@ -166,7 +216,7 @@ def test_gdsii_lazy_port_overrides_without_data_stay_lazy(tmp_path: Path) -> Non
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overrides')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_port_overrides({
|
||||
processed = PortsLibraryView(raw, ports={
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
|
|
@ -189,7 +239,8 @@ def test_gdsii_lazy_port_overrides_apply_after_extraction(tmp_path: Path) -> Non
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-override-extracted')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(
|
||||
processed = PortsLibraryView(
|
||||
raw,
|
||||
layers=[(10, 0)],
|
||||
max_depth=2,
|
||||
ports={
|
||||
|
|
@ -217,7 +268,8 @@ def test_gdsii_lazy_port_overrides_replace_extracted_ports(tmp_path: Path) -> No
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-replace-ports')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(
|
||||
processed = PortsLibraryView(
|
||||
raw,
|
||||
layers=[(10, 0)],
|
||||
max_depth=2,
|
||||
ports={
|
||||
|
|
@ -240,7 +292,7 @@ def test_gdsii_lazy_overlay_add_source_stays_lazy_for_processed_view(tmp_path: P
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||
processed = PortsLibraryView(raw, layers=[(10, 0)], max_depth=2)
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(processed)
|
||||
|
|
@ -258,7 +310,7 @@ def test_gdsii_lazy_overlay_add_source_sees_port_overrides(tmp_path: Path) -> No
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-overlay-override')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_port_overrides({
|
||||
processed = PortsLibraryView(raw, ports={
|
||||
'top': {
|
||||
'P': Port((1, 2), rotation=0, ptype='wire'),
|
||||
},
|
||||
|
|
@ -310,7 +362,7 @@ def test_gdsii_lazy_processed_write_roundtrips_without_explicit_units(tmp_path:
|
|||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='classic-roundtrip')
|
||||
|
||||
raw, _ = gdsii_lazy.readfile(gds_file)
|
||||
processed = raw.with_ports_from_data(layers=[(10, 0)], max_depth=2)
|
||||
processed = PortsLibraryView(raw, layers=[(10, 0)], max_depth=2)
|
||||
|
||||
out_file = tmp_path / 'lazy_roundtrip_out.gds'
|
||||
gdsii_lazy.writefile(processed, out_file)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import pytest
|
|||
pytest.importorskip('pyarrow')
|
||||
|
||||
from .. import PatternError
|
||||
from ..library import IBorrowing, IMaterializable, Library, OverlayLibrary
|
||||
from ..library import IBorrowing, IMaterializable, Library, OverlayLibrary, PortsLibraryView
|
||||
from ..pattern import Pattern
|
||||
from ..repetition import Grid
|
||||
from ..file import gdsii
|
||||
from ..file.gdsii import lazy_arrow as gdsii_lazy_arrow
|
||||
from ..file.gdsii import lazy_write as gdsii_lazy_write
|
||||
from ..file.gdsii.perf import write_fixture
|
||||
|
||||
|
||||
|
|
@ -160,6 +161,17 @@ def test_gdsii_lazy_arrow_local_and_global_refs(tmp_path: Path) -> None:
|
|||
assert global_refs[('top', 'mid', 'leaf')].shape[0] == 5
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_graph_hooks_observe_cached_edits(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'lazy_arrow_cached_graph.gds'
|
||||
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9)
|
||||
|
||||
lib, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
del lib['mid'].refs['leaf']
|
||||
|
||||
assert lib.child_graph(dangling='ignore')['mid'] == set()
|
||||
assert lib.find_refs_local('leaf') == {}
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_ref_queries_match_eager_reader(tmp_path: Path) -> None:
|
||||
gds_file = tmp_path / 'complex_refs.gds'
|
||||
src = _make_complex_ref_library()
|
||||
|
|
@ -223,18 +235,66 @@ def test_gdsii_lazy_arrow_untouched_write_is_copy_through(tmp_path: Path) -> Non
|
|||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy(tmp_path: Path) -> None:
|
||||
def test_gdsii_raw_copy_resolves_generic_borrowing_views(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gds_file = tmp_path / 'provenance_source.gds'
|
||||
gdsii.writefile(_make_small_library(), gds_file, meters_per_unit=1e-9, library_name='provenance')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
ports = PortsLibraryView(raw)
|
||||
subtree = ports.subtree('top')
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(subtree)
|
||||
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
out_file = tmp_path / 'provenance_out.gds'
|
||||
gdsii_lazy_arrow.writefile(overlay, out_file)
|
||||
|
||||
assert copied == ['leaf', 'mid', 'top']
|
||||
assert out_file.read_bytes() == gds_file.read_bytes()
|
||||
|
||||
renamed = OverlayLibrary()
|
||||
renamed.add_source(raw)
|
||||
renamed.rename('top', 'renamed_top')
|
||||
assert gdsii_lazy_write._resolve_raw_struct(renamed, 'renamed_top') is None
|
||||
|
||||
remapped = OverlayLibrary()
|
||||
remapped.add_source(raw)
|
||||
remapped.rename('leaf', 'renamed_leaf', move_references=True)
|
||||
assert gdsii_lazy_write._resolve_raw_struct(remapped, 'mid') is None
|
||||
|
||||
|
||||
def test_gdsii_lazy_arrow_processed_cell_edit_disables_raw_copy(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
gds_file = tmp_path / 'processed_edit_source.gds'
|
||||
src = _make_small_library()
|
||||
gdsii.writefile(src, gds_file, meters_per_unit=1e-9, library_name='processed-edit')
|
||||
|
||||
raw, _ = gdsii_lazy_arrow.readfile(gds_file)
|
||||
processed = raw.with_port_overrides({})
|
||||
processed = PortsLibraryView(raw)
|
||||
processed['top'].polygon((7, 0), vertices=[[0, 0], [4, 0], [0, 4]])
|
||||
|
||||
copied: list[str] = []
|
||||
raw_reader = raw.raw_struct_bytes
|
||||
|
||||
def record_raw_read(name: str) -> bytes:
|
||||
copied.append(name)
|
||||
return raw_reader(name)
|
||||
|
||||
monkeypatch.setattr(raw, 'raw_struct_bytes', record_raw_read)
|
||||
|
||||
out_file = tmp_path / 'processed_edit_out.gds'
|
||||
gdsii_lazy_arrow.writefile(processed, out_file)
|
||||
|
||||
assert 'top' not in copied
|
||||
roundtrip, _ = gdsii.readfile(out_file)
|
||||
assert len(roundtrip['top'].shapes[(7, 0)]) == 1
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import pytest
|
||||
from collections.abc import Iterator, Mapping, MutableMapping
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from numpy.testing import assert_allclose
|
||||
from ..library import IBorrowing, INameView, IMaterializable, ILibraryView, Library, LibraryView, LazyLibrary, OverlayLibrary, PortsLibraryView
|
||||
from ..library import IBorrowing, INameView, IMaterializable, Library, LibraryView, LazyLibrary, OverlayLibrary, PortsLibraryView
|
||||
from ..pattern import Pattern
|
||||
from ..error import LibraryError, PatternError
|
||||
from ..ports import Port
|
||||
|
|
@ -673,41 +673,43 @@ def test_library_materialization_and_borrowing_capabilities() -> None:
|
|||
assert not isinstance(plain_view, IMaterializable | IBorrowing)
|
||||
|
||||
|
||||
class _RawCopyView(ILibraryView):
|
||||
def __init__(self) -> None:
|
||||
self.mapping = {"top": Pattern()}
|
||||
def test_borrowed_source_cell_tracks_persistent_materialization() -> None:
|
||||
source = Library({"top": Pattern()})
|
||||
source.library_info = {"name": "not-forwarded"} # type: ignore[attr-defined]
|
||||
processed = PortsLibraryView(source)
|
||||
|
||||
def __getitem__(self, key: str) -> Pattern:
|
||||
return self.mapping[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.mapping)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.mapping)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.mapping
|
||||
|
||||
def raw_struct_bytes(self, name: str) -> bytes:
|
||||
return name.encode()
|
||||
|
||||
def can_copy_raw_struct(self, name: str) -> bool:
|
||||
return name in self.mapping
|
||||
|
||||
|
||||
def test_ports_view_raw_copy_eligibility_tracks_persistent_materialization() -> None:
|
||||
processed = PortsLibraryView(_RawCopyView())
|
||||
|
||||
assert processed.can_copy_raw_struct("top")
|
||||
assert processed.source_cell("top") == (source, "top")
|
||||
assert not hasattr(processed, "library_info")
|
||||
assert not hasattr(processed, "raw_struct_bytes")
|
||||
_ = processed.materialize_many(("top",), persist=False)
|
||||
assert processed.can_copy_raw_struct("top")
|
||||
assert processed.source_cell("top") == (source, "top")
|
||||
|
||||
subtree = processed.subtree("top")
|
||||
assert subtree.can_copy_raw_struct("top")
|
||||
assert subtree.source_cell("top") == (processed, "top")
|
||||
assert not hasattr(subtree, "library_info")
|
||||
_ = subtree["top"]
|
||||
assert not processed.can_copy_raw_struct("top")
|
||||
assert not subtree.can_copy_raw_struct("top")
|
||||
assert processed.source_cell("top") is None
|
||||
assert subtree.source_cell("top") == (processed, "top")
|
||||
|
||||
|
||||
def test_overlay_source_cell_tracks_names_references_and_materialization() -> None:
|
||||
source = Library({"leaf": Pattern(), "parent": Pattern()})
|
||||
source["parent"].ref("leaf")
|
||||
|
||||
overlay = OverlayLibrary()
|
||||
overlay.add_source(source)
|
||||
assert overlay.source_cell("parent") == (source, "parent")
|
||||
|
||||
overlay.rename("parent", "renamed_parent")
|
||||
assert overlay.source_cell("renamed_parent") == (source, "parent")
|
||||
|
||||
overlay.rename("leaf", "renamed_leaf", move_references=True)
|
||||
assert overlay.source_cell("renamed_parent") is None
|
||||
|
||||
materialized = OverlayLibrary()
|
||||
materialized.add_source(source)
|
||||
_ = materialized["parent"]
|
||||
assert materialized.source_cell("parent") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("materialize_parent", [False, True])
|
||||
|
|
|
|||
338
masque/test/test_tool_contract.py
Normal file
338
masque/test/test_tool_contract.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue