diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fa0716a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: astral-sh/setup-uv@v6 + - run: | + sudo apt-get update + sudo apt-get install --yes libegl1 + - run: uv sync --locked --extra test + - run: uv run pytest + env: + QT_QPA_PLATFORM: offscreen diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..197d3ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Project Guidelines + +## Architecture + +- Keep `LayoutScene`, camera, mesh preparation, and picking independent of Qt and + external data sources. Scenes are immutable snapshots. +- Keep source/session adapters outside the renderer; pass captured data as a + `LayoutScene`. +- Keep Qt window orchestration in `qt/ui/` and GPU rendering in `qt/render/`. + The renderer must not depend on window or panel code. +- When changing capture or incremental updates, keep scene data, prepared + geometry, and picking behavior consistent. + +## Code and Tests + +- Follow the existing Python style, type annotations, and validation patterns. +- Add focused pytest coverage for behavior changes, especially scene invariants, + camera transforms, picking, capture, and incremental updates. +- Run `uv run pytest`; use `QT_QPA_PLATFORM=offscreen uv run pytest` in + headless environments. +- After editing shader sources, regenerate shader packs with + `uv run python tools/bake_viewer_shaders.py`. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the module map, dependency boundaries, +development setup, and additional testing notes. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5fe960c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +from layout_viewer.scene import LayoutScene, SceneLayer, SceneObjectInfo, ScenePad, ScenePolygon, SceneTrace + + +@pytest.fixture +def sample_scene() -> LayoutScene: + return LayoutScene( + layers=( + SceneLayer("F.Cu", (1.0, 0.0, 0.0, 1.0)), + SceneLayer("B.Cu", (0.0, 0.0, 1.0, 1.0)), + ), + polygons=(ScenePolygon(1, "F.Cu", "GND", ((0, 0), (10, 0), (10, 10), (0, 10))),), + traces=(SceneTrace(2, "F.Cu", "SIG", (12, 2), (18, 2), 1),), + pads=(ScenePad(3, "B.Cu", "GND", (20, 5), (2, 2)),), + object_info=( + SceneObjectInfo(1, "zone", "GND"), + SceneObjectInfo(2, "trace", "SIG"), + SceneObjectInfo(3, "pad", "GND"), + ), + ) + + +@pytest.fixture(scope="session") +def qapp(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..b6b8e61 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,41 @@ +import pytest + +from layout_viewer.camera import Camera + + +@pytest.mark.parametrize("mirror", [False, True]) +def test_camera_coordinate_transforms_round_trip(mirror: bool) -> None: + camera = Camera(center_x=12.5, center_y=-3.0, scale=7.25, width=800, height=600, mirror=mirror) + world = (15.25, 4.5) + assert camera.screen_to_world(*camera.world_to_screen(*world)) == pytest.approx(world) + + +def test_fit_centers_bounds_and_respects_padding() -> None: + camera = Camera() + camera.resize(800, 600) + camera.fit((0, 0, 100, 50), (100, 20, 100, 80)) + + assert (camera.center_x, camera.center_y) == pytest.approx((50, 25 - 60 / (2 * camera.scale))) + assert camera.scale == pytest.approx(0.9 * min(600 / 100, 500 / 50)) + + +def test_zoom_at_keeps_world_point_under_cursor() -> None: + camera = Camera(center_x=8, center_y=-2, scale=5, width=640, height=480) + anchor = camera.screen_to_world(125, 360) + camera.zoom_at(125, 360, 2.5) + assert camera.screen_to_world(125, 360) == pytest.approx(anchor) + assert camera.scale == pytest.approx(12.5) + + +@pytest.mark.parametrize("factor", [0, -1, float("inf"), float("nan")]) +def test_zoom_rejects_nonpositive_or_nonfinite_factors(factor: float) -> None: + with pytest.raises(ValueError, match="positive and finite"): + Camera().zoom_at(0, 0, factor) + + +def test_pan_moves_world_in_drag_direction() -> None: + camera = Camera(scale=4, width=100, height=100) + before = camera.screen_to_world(50, 50) + camera.pan(20, -8) + after = camera.screen_to_world(50, 50) + assert after == pytest.approx((before[0] - 5, before[1] - 2)) diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..6ef3bcf --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass +from threading import RLock + +import pytest + +from layout_viewer.capture import SourceCapture +from layout_viewer.scene import LayoutScene, SceneLayer, SceneObjectInfo, ScenePolygon + + +@dataclass +class Delta: + revision: int + upserts: tuple = () + removed: frozenset = frozenset() + full: bool = False + + +class ChangeJournal: + def __init__(self) -> None: + self.lock = RLock() + self.revision = 1 + self.closed = False + self.delta = Delta(1, full=True) + + def since(self, revision: int) -> Delta: + return self.delta + + +def _scene(*polygons: ScenePolygon, infos=()) -> LayoutScene: + return LayoutScene( + (SceneLayer("F.Cu", (1, 0, 0, 1)),), + polygons, + object_info=infos, + ) + + +def _polygon(object_id: int, x: float) -> ScenePolygon: + return ScenePolygon(object_id, "F.Cu", None, ((x, 0), (x + 1, 0), (x + 1, 1), (x, 1))) + + +def test_full_capture_records_scene_revision_and_delta_replaces_changed_objects() -> None: + original, removed, untouched = _polygon(1, 0), _polygon(2, 3), _polygon(3, 6) + replacement = _polygon(1, 10) + changes = ChangeJournal() + full_loader_calls = [] + delta_loader_calls = [] + source = SourceCapture( + lambda progress, **kwargs: (full_loader_calls.append(progress) or _scene(original, removed, untouched)), + changes=changes, + delta_loader=lambda delta, previous, progress: ( + delta_loader_calls.append(delta) or _scene(replacement, infos=(SceneObjectInfo(1, "zone"),)) + ), + ) + + initial = source.capture(full=True) + source.revision = initial.revision + assert initial.scene.polygons == (original, removed, untouched) + assert initial.revision == 1 + + changes.revision = 2 + changes.delta = Delta(2, upserts=((1, object()),), removed=frozenset({1, 2})) + updated = source.capture(previous=initial.scene) + + assert full_loader_calls == [None] + assert len(delta_loader_calls) == 1 + assert updated.revision == 2 + assert updated.changed == frozenset({1, 2}) + assert updated.scene.polygons == (untouched, replacement) + assert updated.scene.polygons[0] is untouched + assert updated.scene.object_info == (SceneObjectInfo(1, "zone"),) + + +def test_empty_delta_reuses_previous_scene_without_loading() -> None: + previous = _scene(_polygon(1, 0)) + changes = ChangeJournal() + changes.delta = Delta(2) + source = SourceCapture( + lambda *args, **kwargs: pytest.fail("full loader should not run"), + changes=changes, + delta_loader=lambda *args: pytest.fail("delta loader should not run"), + ) + source.revision = 1 + + result = source.capture(previous=previous) + + assert result.scene is previous + assert result.revision == 2 + assert result.changed == frozenset() + + +def test_full_delta_falls_back_to_full_loader() -> None: + replacement = _polygon(1, 7) + changes = ChangeJournal() + changes.revision = 3 + changes.delta = Delta(3, full=True) + source = SourceCapture(lambda *args, **kwargs: _scene(replacement), changes=changes) + + result = source.capture(previous=_scene(_polygon(1, 0))) + + assert result.revision == 3 + assert result.scene.polygons == (replacement,) + + +def test_closed_source_is_reported() -> None: + changes = ChangeJournal() + changes.closed = True + source = SourceCapture(lambda *args, **kwargs: _scene(), changes=changes) + + with pytest.raises(RuntimeError, match="source session is closed"): + source.capture() + + +def test_capture_failure_does_not_advance_committed_revision() -> None: + changes = ChangeJournal() + source = SourceCapture( + lambda *args, **kwargs: (_ for _ in ()).throw(ValueError("capture failed")), + changes=changes, + ) + source.revision = 1 + changes.revision = 2 + + with pytest.raises(ValueError, match="capture failed"): + source.capture(full=True) + + assert source.revision == 1 diff --git a/tests/test_live_refresh.py b/tests/test_live_refresh.py new file mode 100644 index 0000000..11cd519 --- /dev/null +++ b/tests/test_live_refresh.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from threading import RLock + +from layout_viewer.capture import SourceCapture +from layout_viewer.mesh import prepare_scene +from layout_viewer.qt.ui.window import _Prepare +from layout_viewer.scene import LayoutScene, SceneLayer, SceneObjectInfo, ScenePolygon + + +@dataclass +class Delta: + revision: int + upserts: tuple = () + removed: frozenset = frozenset() + full: bool = False + + +class ChangeJournal: + def __init__(self) -> None: + self.lock = RLock() + self.revision = 1 + self.closed = False + self.delta = Delta(1, full=True) + + def since(self, revision: int) -> Delta: + return self.delta + + +def _polygon(object_id: int, x: float) -> ScenePolygon: + return ScenePolygon(object_id, "F.Cu", "GND", ((x, 0), (x + 2, 0), (x + 2, 2), (x, 2))) + + +def _scene(*polygons: ScenePolygon, infos=None) -> LayoutScene: + return LayoutScene( + (SceneLayer("F.Cu", (1, 0, 0, 1)),), + polygons, + object_info=( + tuple(SceneObjectInfo(item.object_id, "zone", item.net) for item in polygons) if infos is None else infos + ), + ) + + +def _run_prepare(preparation: _Prepare): + results = {} + preparation.signals.scene_ready.connect(lambda scene: results.__setitem__("scene", scene)) + preparation.signals.ready.connect(lambda prepared: results.__setitem__("prepared", prepared)) + preparation.signals.failed.connect(lambda error: results.__setitem__("error", error)) + preparation.run() + assert "error" not in results, results.get("error") + return results["scene"], results["prepared"] + + +def test_incremental_source_refresh_updates_scene_mesh_and_picking() -> None: + original, unchanged = _polygon(1, 0), _polygon(2, 5) + replacement = _polygon(1, 10) + changes = ChangeJournal() + full_calls = [] + source = SourceCapture( + lambda *args, **kwargs: (full_calls.append(True) or _scene(original, unchanged)), + changes=changes, + delta_loader=lambda delta, previous, progress: _scene( + replacement, + infos=(SceneObjectInfo(1, "zone", "GND"),), + ), + ) + + initial_scene, initial_prepared = _run_prepare(_Prepare(_scene(), source, previews=False)) + source.revision = changes.revision + changes.revision = 2 + changes.delta = Delta(2, upserts=((1, object()),), removed=frozenset({1})) + + updated_scene, updated_prepared = _run_prepare( + _Prepare( + initial_scene, + source, + previews=False, + incremental=True, + previous=(initial_scene, initial_prepared), + ) + ) + + assert full_calls == [True] + assert updated_scene is not initial_scene + assert updated_scene.polygons == (unchanged, replacement) + assert updated_scene.polygons[0] is unchanged + assert updated_prepared.mesh_parent_id == id(initial_prepared) + assert updated_prepared.picking.pick((11, 1), 100, {"F.Cu"}).object.info.object_id == 1 + assert updated_prepared.picking.pick((1, 1), 100, {"F.Cu"}) is None diff --git a/tests/test_mesh.py b/tests/test_mesh.py new file mode 100644 index 0000000..a9c084b --- /dev/null +++ b/tests/test_mesh.py @@ -0,0 +1,29 @@ +import numpy as np + +from layout_viewer.mesh import prepare_scene +from layout_viewer.scene import LayoutScene + + +def test_prepare_scene_builds_polygon_and_analytic_geometry(sample_scene) -> None: + prepared = prepare_scene(sample_scene) + + assert len(prepared.batches) == 1 + assert prepared.batches[0].object_id == 1 + assert prepared.batches[0].ring_sizes == (4,) + assert len(prepared.vertices) == 4 + assert len(prepared.indices) == 6 + assert prepared.instances.shape == (2, 40) + assert prepared.picking is not None + assert prepared.picking.pick((5, 5), 100, {"F.Cu"}).object.info.object_id == 1 + assert not prepared.sdf_sources.flags.writeable + + +def test_prepare_scene_reports_progress_and_supports_empty_scene() -> None: + progress = [] + prepared = prepare_scene(LayoutScene((), ()), progress=lambda *event: progress.append(event)) + + assert prepared.vertices.shape == (0, 4) + assert prepared.indices.dtype == np.uint32 + assert prepared.instances.shape == (0, 40) + assert prepared.picking.pick((0, 0), 1, set()) is None + assert progress[-1][1:] == (1, 1) diff --git a/tests/test_picking.py b/tests/test_picking.py new file mode 100644 index 0000000..aeddf78 --- /dev/null +++ b/tests/test_picking.py @@ -0,0 +1,46 @@ +from layout_viewer.picking import PickIndex +from layout_viewer.scene import LayoutScene, SceneLayer, ScenePolygon + + +def test_pick_index_applies_layer_network_and_opacity_filters(sample_scene) -> None: + index = PickIndex(sample_scene) + + assert index.pick((5, 5), 100, {"F.Cu"}).object.info.object_id == 1 + assert index.pick((5, 5), 100, {"B.Cu"}) is None + assert index.pick((5, 5), 100, {"F.Cu"}, net="SIG") is None + assert index.pick((5, 5), 100, {"F.Cu"}, net="GND").object.info.kind == "zone" + assert index.pick((5, 5), 100, {"F.Cu"}, opacity=0) is None + + +def test_pick_index_picks_trace_and_pad(sample_scene) -> None: + index = PickIndex(sample_scene) + + trace = index.pick((15, 2), 100, {"F.Cu"}) + pad = index.pick((20, 5), 100, {"B.Cu"}) + + assert trace is not None and trace.object.info.object_id == 2 + assert pad is not None and pad.object.info.object_id == 3 + + +def test_pick_index_does_not_pick_inside_polygon_hole() -> None: + scene = LayoutScene( + (SceneLayer("F.Cu", (1, 0, 0, 1)),), + ( + ScenePolygon( + 7, + "F.Cu", + None, + ((0, 0), (10, 0), (10, 10), (0, 10)), + holes=(((4, 4), (6, 4), (6, 6), (4, 6)),), + ), + ), + ) + index = PickIndex(scene) + + assert index.pick((2, 2), 100, {"F.Cu"}) is not None + assert index.pick((5, 5), 100, {"F.Cu"}) is None + + +def test_empty_pick_index_returns_no_hit() -> None: + index = PickIndex(LayoutScene((), ())) + assert index.pick((0, 0), 1, set()) is None diff --git a/tests/test_qt_interaction.py b/tests/test_qt_interaction.py new file mode 100644 index 0000000..7bcccc1 --- /dev/null +++ b/tests/test_qt_interaction.py @@ -0,0 +1,72 @@ +from PySide6.QtCore import QPoint +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest + +from layout_viewer.mesh import prepare_scene +from layout_viewer.qt.view.canvas import LayoutView + + +def test_canvas_click_selects_and_escape_clears_selection(qapp, sample_scene) -> None: + canvas = LayoutView(sample_scene, backend="vulkan") + canvas.resize(200, 200) + canvas.camera.resize(200, 200) + canvas.camera.center_x = canvas.camera.center_y = 5 + canvas.camera.scale = 10 + canvas.visible_layers = {"F.Cu"} + canvas.set_prepared(prepare_scene(sample_scene)) + selected = [] + canvas.selection_changed.connect(selected.append) + + QTest.mouseClick(canvas, Qt.MouseButton.LeftButton, pos=QPoint(100, 100)) + + assert canvas.selection is not None + assert canvas.selection.object.info.object_id == 1 + assert selected[-1].object_id == 1 + + QTest.keyClick(canvas, Qt.Key.Key_Escape) + + assert canvas.selection is None + assert selected[-1] is None + canvas.cleanup() + + +def test_canvas_pick_tool_can_be_disabled(qapp, sample_scene) -> None: + canvas = LayoutView(sample_scene, backend="vulkan") + canvas.camera.resize(200, 200) + canvas.camera.center_x = canvas.camera.center_y = 5 + canvas.camera.scale = 10 + canvas.set_prepared(prepare_scene(sample_scene)) + canvas.set_pick_enabled(False) + + assert canvas.pick_at(100, 100) is None + assert canvas.selection is None + canvas.cleanup() + + +def test_canvas_pick_respects_visible_layer_and_network_filters(qapp, sample_scene) -> None: + canvas = LayoutView(sample_scene, backend="vulkan") + canvas.camera.resize(200, 200) + canvas.camera.center_x = canvas.camera.center_y = 5 + canvas.camera.scale = 10 + canvas.set_prepared(prepare_scene(sample_scene)) + canvas.net = "SIG" + canvas.visible_layers = {"F.Cu"} + + assert canvas.pick_at(100, 100) is None + canvas.net = "GND" + assert canvas.pick_at(100, 100).object.info.object_id == 1 + canvas.visible_layers = {"B.Cu"} + assert canvas.pick_at(100, 100) is None + canvas.cleanup() + + +def test_canvas_zoom_changes_camera_scale(qapp, sample_scene) -> None: + canvas = LayoutView(sample_scene, backend="vulkan") + canvas.resize(200, 200) + canvas.camera.resize(200, 200) + initial_scale = canvas.camera.scale + + canvas.zoom(1.5) + + assert canvas.camera.scale == initial_scale * 1.5 + canvas.cleanup() diff --git a/tests/test_scene.py b/tests/test_scene.py new file mode 100644 index 0000000..52f7c12 --- /dev/null +++ b/tests/test_scene.py @@ -0,0 +1,33 @@ +from dataclasses import FrozenInstanceError + +import pytest + +from layout_viewer.scene import LayoutScene, SceneLayer, ScenePolygon + + +def test_scene_normalizes_collections_and_reports_geometry_bounds(sample_scene) -> None: + assert isinstance(sample_scene.layers, tuple) + assert isinstance(sample_scene.polygons, tuple) + assert tuple(item.object_id for item in sample_scene.geometry) == (1, 2, 3) + assert sample_scene.bounds == (0.0, 0.0, 21.0, 10.0) + + +def test_scene_is_immutable(sample_scene) -> None: + with pytest.raises(FrozenInstanceError): + sample_scene.title = "changed" + + +def test_scene_rejects_duplicate_layers() -> None: + layer = SceneLayer("F.Cu", (1, 0, 0, 1)) + with pytest.raises(ValueError, match="unique"): + LayoutScene((layer, layer), ()) + + +def test_scene_rejects_geometry_on_unknown_layer() -> None: + polygon = ScenePolygon(1, "missing", None, ((0, 0), (1, 0), (1, 1))) + with pytest.raises(ValueError, match="Every object"): + LayoutScene((SceneLayer("F.Cu", (1, 0, 0, 1)),), (polygon,)) + + +def test_empty_scene_has_no_bounds() -> None: + assert LayoutScene((), ()).bounds is None