masque/masque/test/test_library.py

346 lines
10 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
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_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)