masque/masque/test/test_library.py

483 lines
15 KiB
Python

import pytest
from typing import cast, TYPE_CHECKING
from numpy.testing import assert_allclose
from ..library import Library, LazyLibrary
from ..pattern import Pattern
from ..error import LibraryError, PatternError
from ..ports import Port
from ..repetition import Grid
from ..shapes import Arc, Ellipse, Path, Text
from ..file.utils import preflight
if TYPE_CHECKING:
from ..shapes import Polygon
def test_library_basic() -> None:
lib = Library()
pat = Pattern()
lib["cell1"] = pat
assert "cell1" in lib
assert lib["cell1"] is pat
assert len(lib) == 1
with pytest.raises(LibraryError):
lib["cell1"] = Pattern() # Overwriting not allowed
def test_library_tops() -> None:
lib = Library()
lib["child"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("child")
assert set(lib.tops()) == {"parent"}
assert lib.top() == "parent"
def test_library_dangling() -> None:
lib = Library()
lib["parent"] = Pattern()
lib["parent"].ref("missing")
assert lib.dangling_refs() == {"missing"}
def test_library_dangling_graph_modes() -> None:
lib = Library()
lib["parent"] = Pattern()
lib["parent"].ref("missing")
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.child_graph()
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.parent_graph()
with pytest.raises(LibraryError, match="Dangling refs found"):
lib.child_order()
assert lib.child_graph(dangling="ignore") == {"parent": set()}
assert lib.parent_graph(dangling="ignore") == {"parent": set()}
assert lib.child_order(dangling="ignore") == ["parent"]
assert lib.child_graph(dangling="include") == {"parent": {"missing"}, "missing": set()}
assert lib.parent_graph(dangling="include") == {"parent": set(), "missing": {"parent"}}
assert lib.child_order(dangling="include") == ["missing", "parent"]
def test_find_refs_with_dangling_modes() -> None:
lib = Library()
lib["target"] = Pattern()
mid = Pattern()
mid.ref("target", offset=(2, 0))
lib["mid"] = mid
top = Pattern()
top.ref("mid", offset=(5, 0))
top.ref("missing", offset=(9, 0))
lib["top"] = top
assert lib.find_refs_local("missing", dangling="ignore") == {}
assert lib.find_refs_global("missing", dangling="ignore") == {}
local_missing = lib.find_refs_local("missing", dangling="include")
assert set(local_missing) == {"top"}
assert_allclose(local_missing["top"][0], [[9, 0, 0, 0, 1]])
global_missing = lib.find_refs_global("missing", dangling="include")
assert_allclose(global_missing[("top", "missing")], [[9, 0, 0, 0, 1]])
with pytest.raises(LibraryError, match="missing"):
lib.find_refs_local("missing")
with pytest.raises(LibraryError, match="missing"):
lib.find_refs_global("missing")
global_target = lib.find_refs_global("target")
assert_allclose(global_target[("top", "mid", "target")], [[7, 0, 0, 0, 1]])
def test_preflight_prune_empty_preserves_dangling_policy(caplog: pytest.LogCaptureFixture) -> None:
def make_lib() -> Library:
lib = Library()
lib["empty"] = Pattern()
lib["top"] = Pattern()
lib["top"].ref("missing")
return lib
caplog.set_level("WARNING")
warned = preflight(make_lib(), allow_dangling_refs=None, prune_empty_patterns=True)
assert "empty" not in warned
assert any("Dangling refs found" in record.message for record in caplog.records)
allowed = preflight(make_lib(), allow_dangling_refs=True, prune_empty_patterns=True)
assert "empty" not in allowed
with pytest.raises(LibraryError, match="Dangling refs found"):
preflight(make_lib(), allow_dangling_refs=False, prune_empty_patterns=True)
def test_library_flatten() -> None:
lib = Library()
child = Pattern()
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
lib["child"] = child
parent = Pattern()
parent.ref("child", offset=(10, 10))
lib["parent"] = parent
flat_lib = lib.flatten("parent")
flat_parent = flat_lib["parent"]
assert not flat_parent.has_refs()
assert len(flat_parent.shapes[(1, 0)]) == 1
# Transformations are baked into vertices for Polygon
assert_vertices = cast("Polygon", flat_parent.shapes[(1, 0)][0]).vertices
assert tuple(assert_vertices[0]) == (10.0, 10.0)
def test_library_flatten_preserves_ports_only_child() -> None:
lib = Library()
child = Pattern(ports={"P1": Port((1, 2), 0)})
lib["child"] = child
parent = Pattern()
parent.ref("child", offset=(10, 10))
lib["parent"] = parent
flat_parent = lib.flatten("parent", flatten_ports=True)["parent"]
assert set(flat_parent.ports) == {"P1"}
assert cast("Port", flat_parent.ports["P1"]).rotation == 0
assert tuple(flat_parent.ports["P1"].offset) == (11.0, 12.0)
def test_library_flatten_repeated_ref_with_ports_raises() -> None:
lib = Library()
child = Pattern(ports={"P1": Port((1, 2), 0)})
child.polygon((1, 0), vertices=[[0, 0], [1, 0], [0, 1]])
lib["child"] = child
parent = Pattern()
parent.ref("child", repetition=Grid(a_vector=(10, 0), a_count=2))
lib["parent"] = parent
with pytest.raises(PatternError, match='Cannot flatten ports from repeated ref'):
lib.flatten("parent", flatten_ports=True)
def test_library_flatten_dangling_ok_nested_preserves_dangling_refs() -> None:
lib = Library()
child = Pattern()
child.ref("missing")
lib["child"] = child
parent = Pattern()
parent.ref("child")
lib["parent"] = parent
flat = lib.flatten("parent", dangling_ok=True)
assert set(flat["child"].refs) == {"missing"}
assert flat["child"].has_refs()
assert set(flat["parent"].refs) == {"missing"}
assert flat["parent"].has_refs()
def test_lazy_library() -> None:
lib = LazyLibrary()
called = 0
def make_pat() -> Pattern:
nonlocal called
called += 1
return Pattern()
lib["lazy"] = make_pat
assert called == 0
pat = lib["lazy"]
assert called == 1
assert isinstance(pat, Pattern)
# Second access should be cached
pat2 = lib["lazy"]
assert called == 1
assert pat is pat2
def test_library_rename() -> None:
lib = Library()
lib["old"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("old")
lib.rename("old", "new", move_references=True)
assert "old" not in lib
assert "new" in lib
assert "new" in lib["parent"].refs
assert "old" not in lib["parent"].refs
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_rename_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("top")
lib.rename("top", "top", move_references=True)
assert set(lib.keys()) == {"top", "parent"}
assert "top" in lib["parent"].refs
assert len(lib["parent"].refs["top"]) == 1
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_rename_top_self_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
lib.rename_top("top")
assert list(lib.keys()) == ["top"]
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_rename_missing_raises_library_error(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
with pytest.raises(LibraryError, match="does not exist"):
lib.rename("missing", "new")
@pytest.mark.parametrize("library_cls", (Library, LazyLibrary))
def test_library_move_references_same_target_is_noop(library_cls: type[Library] | type[LazyLibrary]) -> None:
lib = library_cls()
lib["top"] = Pattern()
lib["parent"] = Pattern()
lib["parent"].ref("top")
lib.move_references("top", "top")
assert "top" in lib["parent"].refs
assert len(lib["parent"].refs["top"]) == 1
def test_library_dfs_can_replace_existing_patterns() -> None:
lib = Library()
child = Pattern()
lib["child"] = child
top = Pattern()
top.ref("child")
lib["top"] = top
replacement_top = Pattern(ports={"T": Port((1, 2), 0)})
replacement_child = Pattern(ports={"C": Port((3, 4), 0)})
def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001
if hierarchy[-1] == "child":
return replacement_child
if hierarchy[-1] == "top":
return replacement_top
return pattern
lib.dfs(lib["top"], visit_after=visit_after, hierarchy=("top",), transform=True)
assert lib["top"] is replacement_top
assert lib["child"] is replacement_child
def test_lazy_library_dfs_can_replace_existing_patterns() -> None:
lib = LazyLibrary()
lib["child"] = lambda: Pattern()
lib["top"] = lambda: Pattern(refs={"child": []})
top = lib["top"]
top.ref("child")
replacement_top = Pattern(ports={"T": Port((1, 2), 0)})
replacement_child = Pattern(ports={"C": Port((3, 4), 0)})
def visit_after(pattern: Pattern, hierarchy: tuple[str | None, ...], **kwargs) -> Pattern: # noqa: ARG001
if hierarchy[-1] == "child":
return replacement_child
if hierarchy[-1] == "top":
return replacement_top
return pattern
lib.dfs(top, visit_after=visit_after, hierarchy=("top",), transform=True)
assert lib["top"] is replacement_top
assert lib["child"] is replacement_child
def test_library_add_no_duplicates_respects_mutate_other_false() -> None:
src_pat = Pattern(ports={"A": Port((0, 0), 0)})
lib = Library({"a": Pattern()})
lib.add({"b": src_pat}, mutate_other=False)
assert lib["b"] is not src_pat
lib["b"].ports["A"].offset[0] = 123
assert tuple(src_pat.ports["A"].offset) == (0.0, 0.0)
def test_library_add_returns_only_renamed_entries() -> None:
lib = Library({"a": Pattern(), "_shape": Pattern()})
assert lib.add({"b": Pattern(), "c": Pattern()}, mutate_other=False) == {}
rename_map = lib.add({"_shape": Pattern(), "keep": Pattern()}, mutate_other=False)
assert set(rename_map) == {"_shape"}
assert rename_map["_shape"] != "_shape"
assert "keep" not in rename_map
def test_library_subtree() -> None:
lib = Library()
lib["a"] = Pattern()
lib["b"] = Pattern()
lib["c"] = Pattern()
lib["a"].ref("b")
sub = lib.subtree("a")
assert "a" in sub
assert "b" in sub
assert "c" not in sub
def test_library_child_order_cycle_raises_library_error() -> None:
lib = Library()
lib["a"] = Pattern()
lib["a"].ref("b")
lib["b"] = Pattern()
lib["b"].ref("a")
with pytest.raises(LibraryError, match="Cycle found while building child order"):
lib.child_order()
def test_library_find_refs_global_cycle_raises_library_error() -> None:
lib = Library()
lib["a"] = Pattern()
lib["a"].ref("a")
with pytest.raises(LibraryError, match="Cycle found while building child order"):
lib.find_refs_global("a")
def test_library_get_name() -> None:
lib = Library()
lib["cell"] = Pattern()
name1 = lib.get_name("cell")
assert name1 != "cell"
assert name1.startswith("cell")
name2 = lib.get_name("other")
assert name2 == "other"
def test_library_dedup_shapes_does_not_merge_custom_capped_paths() -> None:
lib = Library()
pat = Pattern()
pat.shapes[(1, 0)] += [
Path(vertices=[[0, 0], [10, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(1, 2)),
Path(vertices=[[20, 0], [30, 0]], width=2, cap=Path.Cap.SquareCustom, cap_extensions=(3, 4)),
]
lib["top"] = pat
lib.dedup(norm_value=1, threshold=2)
assert not lib["top"].refs
assert len(lib["top"].shapes[(1, 0)]) == 2
def test_library_dedup_text_preserves_scale_and_mirror_flag() -> None:
lib = Library()
pat = Pattern()
pat.shapes[(1, 0)] += [
Text("A", 10, "dummy.ttf", offset=(0, 0)),
Text("A", 10, "dummy.ttf", offset=(100, 0)),
]
lib["top"] = pat
lib.dedup(exclude_types=(), norm_value=5, threshold=2)
target_name = next(iter(lib["top"].refs))
refs = lib["top"].refs[target_name]
assert [ref.mirrored for ref in refs] == [False, False]
assert [ref.scale for ref in refs] == [2.0, 2.0]
assert cast("Text", lib[target_name].shapes[(1, 0)][0]).height == 5
flat = lib.flatten("top")["top"]
assert [cast("Text", shape).height for shape in flat.shapes[(1, 0)]] == [10, 10]
def test_library_dedup_handles_arc_and_ellipse_labels() -> None:
lib = Library()
pat = Pattern()
pat.shapes[(1, 0)] += [
Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(0, 0)),
Arc(radii=(10, 20), angles=(0, 1), width=2, offset=(50, 0)),
]
pat.shapes[(2, 0)] += [
Ellipse(radii=(10, 20), offset=(0, 0)),
Ellipse(radii=(10, 20), offset=(50, 0)),
]
lib["top"] = pat
lib.dedup(exclude_types=(), norm_value=1, threshold=2)
assert len(lib["top"].refs) == 2
assert lib["top"].shapes[(1, 0)] == []
assert lib["top"].shapes[(2, 0)] == []
flat = lib.flatten("top")["top"]
assert sum(isinstance(shape, Arc) for shape in flat.shapes[(1, 0)]) == 2
assert sum(isinstance(shape, Ellipse) for shape in flat.shapes[(2, 0)]) == 2
def test_library_dedup_handles_multiple_duplicate_groups() -> None:
from ..shapes import Circle
lib = Library()
pat = Pattern()
pat.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))]
pat.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)]
lib["top"] = pat
lib.dedup(exclude_types=(), norm_value=1, threshold=2)
assert len(lib["top"].refs) == 2
assert all(len(refs) == 2 for refs in lib["top"].refs.values())
assert len(lib["top"].shapes[(1, 0)]) == 0
assert len(lib["top"].shapes[(2, 0)]) == 0
def test_library_dedup_uses_stable_target_names_per_label() -> None:
from ..shapes import Circle
lib = Library()
p1 = Pattern()
p1.shapes[(1, 0)] += [Circle(radius=1, offset=(0, 0)), Circle(radius=1, offset=(10, 0))]
lib["p1"] = p1
p2 = Pattern()
p2.shapes[(2, 0)] += [Path(vertices=[[0, 0], [5, 0]], width=2), Path(vertices=[[10, 0], [15, 0]], width=2)]
lib["p2"] = p2
lib.dedup(exclude_types=(), norm_value=1, threshold=2)
circle_target = next(iter(lib["p1"].refs))
path_target = next(iter(lib["p2"].refs))
assert circle_target != path_target
assert all(isinstance(shape, Circle) for shapes in lib[circle_target].shapes.values() for shape in shapes)
assert all(isinstance(shape, Path) for shapes in lib[path_target].shapes.values() for shape in shapes)