Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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([])
41 changes: 41 additions & 0 deletions tests/test_camera.py
Original file line number Diff line number Diff line change
@@ -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))
125 changes: 125 additions & 0 deletions tests/test_capture.py
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions tests/test_live_refresh.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions tests/test_mesh.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading