proejct commit

This commit is contained in:
2026-09-24 15:45:31 +02:00
commit 82d52be8a5
63 changed files with 8748 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
"""Tests einzelner Widgets (headless) sowie kleiner Hilfsbausteine der Oberfläche."""
import math
from collections.abc import Iterator
import pygame
import pytest
from sandbox.config import INTERACTION, Theme, UiConfig
from sandbox.interaction import ShapeKind, ToolSettings
from sandbox.renderer import interpolate_angle
from sandbox.ui.style import UiStyle, shade
from sandbox.ui.widgets import (
Button,
ColorPicker,
Label,
LabelKind,
SegmentedControl,
Slider,
SpawnTile,
Widget,
)
from sandbox.viewport import Viewport
@pytest.fixture
def style() -> Iterator[UiStyle]:
pygame.init()
yield UiStyle(Theme(), UiConfig())
pygame.quit()
def _event(event_type: int, **attributes: object) -> pygame.event.Event:
return pygame.event.Event(event_type, **attributes)
def _press(pos: tuple[int, int]) -> pygame.event.Event:
return _event(pygame.MOUSEBUTTONDOWN, pos=pos, button=pygame.BUTTON_LEFT)
def _release(pos: tuple[int, int]) -> pygame.event.Event:
return _event(pygame.MOUSEBUTTONUP, pos=pos, button=pygame.BUTTON_LEFT)
def _slider(value: list[float], enabled: bool = True) -> Slider:
return Slider(
pygame.Rect(0, 0, 214, 38),
"Test",
minimum=10,
maximum=110,
step=5,
get_value=lambda: value[0],
set_value=lambda new: value.__setitem__(0, new),
value_format=str,
knob_radius=7,
is_enabled=lambda: enabled,
)
def test_slider_maps_positions_to_snapped_values() -> None:
slider = _slider([10.0])
assert slider.value_at(slider.track_left) == 10
assert slider.value_at(slider.track_left + slider.track_width) == 110
assert slider.value_at(slider.track_left + slider.track_width / 2) == 60
assert slider.value_at(-1000) == 10 # außerhalb wird begrenzt
assert slider.snap(23) == 25
assert slider.x_of(60) == pytest.approx(slider.track_left + slider.track_width / 2)
def test_slider_drag_changes_the_value_until_released() -> None:
value = [10.0]
slider = _slider(value)
assert slider.handle_event(_press((slider.track_left, slider.track_y)), (0, 0))
motion = _event(pygame.MOUSEMOTION, pos=(500, 20))
assert slider.handle_event(motion, (500, 20))
assert value[0] == 110
assert slider.handle_event(_release((500, 20)), (500, 20))
assert not slider.dragging
assert not slider.handle_event(motion, (500, 20)) # nach dem Loslassen: kein Ziehen mehr
def test_disabled_slider_swallows_clicks_but_does_nothing() -> None:
value = [10.0]
slider = _slider(value, enabled=False)
assert slider.handle_event(_press((100, 30)), (100, 30))
assert value[0] == 10
assert not slider.handle_event(_event(pygame.MOUSEWHEEL, x=0, y=1), (100, 30))
def test_slider_rejects_an_empty_range() -> None:
with pytest.raises(ValueError, match="maximum > minimum"):
Slider(
pygame.Rect(0, 0, 100, 38),
"x",
minimum=1,
maximum=1,
step=1,
get_value=lambda: 1.0,
set_value=lambda _: None,
value_format=str,
knob_radius=7,
)
def test_segmented_control_and_color_picker_select_on_click() -> None:
selected: list[object] = [ShapeKind.CIRCLE, None]
control = SegmentedControl(
pygame.Rect(0, 0, 200, 30),
[(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")],
lambda: selected[0],
lambda value: selected.__setitem__(0, value),
)
assert control.handle_event(_press(control.segment_rects()[1].center), (0, 0))
assert selected[0] is ShapeKind.BOX
assert not control.handle_event(_press((500, 500)), (500, 500))
picker = ColorPicker(
pygame.Rect(0, 40, 250, 22),
Theme().body_palette,
lambda: selected[1], # type: ignore[return-value]
lambda index: selected.__setitem__(1, index),
swatch_size=22,
swatch_gap=3,
)
swatches = picker.swatches()
assert swatches[0][0] is None # erstes Feld = Zufallsfarbe
assert picker.handle_event(_press(swatches[2][1].center), (0, 0))
assert selected[1] == 1
assert picker.handle_event(_press(swatches[0][1].center), (0, 0))
assert selected[1] is None
def test_all_widgets_draw_in_every_state(style: UiStyle) -> None:
surface = pygame.Surface((300, 600))
clicks: list[str] = []
tools = ToolSettings.from_config(INTERACTION)
widgets: list[Widget] = [
Label(pygame.Rect(0, 0, 200, 20), "Titel", LabelKind.TITLE),
Label(pygame.Rect(0, 20, 200, 20), lambda: "dynamisch", LabelKind.HINT),
Button(
pygame.Rect(0, 40, 100, 30), "Aktiv", lambda: clicks.append("a"), is_active=lambda: True
),
Button(pygame.Rect(0, 80, 100, 30), "Gefahr", lambda: None, danger=True),
_slider([60.0]),
_slider([60.0], enabled=False),
SpawnTile(
pygame.Rect(0, 300, 228, 92),
lambda: tools,
lambda: (200, 100, 50),
lambda: clicks.append("spawn"),
hint="Hinweis",
),
]
for widget in widgets:
widget.handle_event(_event(pygame.MOUSEMOTION, pos=widget.rect.center), widget.rect.center)
widget.draw(surface, style)
tools.shape = ShapeKind.BOX
widgets[-1].draw(surface, style)
assert widgets[-1].handle_event(_press(widgets[-1].rect.center), (0, 0))
assert clicks == ["spawn"]
def test_base_widget_cannot_be_drawn(style: UiStyle) -> None:
with pytest.raises(NotImplementedError):
Widget(pygame.Rect(0, 0, 1, 1)).draw(pygame.Surface((1, 1)), style)
def test_text_cache_is_bounded(style: UiStyle) -> None:
for number in range(style.ui.text_cache_size + 10):
style.render_text(str(number), style.small_font, (255, 255, 255))
assert len(style._text_cache) <= style.ui.text_cache_size
def test_shade_clamps_to_valid_colors() -> None:
assert shade((100, 200, 250), 0.5) == (50, 100, 125)
assert shade((100, 200, 250), 2.0) == (200, 255, 255)
def test_viewport_maps_between_screen_and_world() -> None:
viewport = Viewport(260, 0, 1020, 720)
point = viewport.to_world((300, 50))
assert (point.x, point.y) == (40, 50)
assert viewport.to_screen(point) == (300, 50)
assert viewport.contains((260, 0))
assert not viewport.contains((259, 10))
assert not viewport.contains((1280, 10))
def test_angle_interpolation_takes_the_short_way_around() -> None:
assert interpolate_angle(0.0, 1.0, 0.5) == pytest.approx(0.5)
# Von knapp unter +π nach knapp über −π ist nur ein kleiner Schritt.
halfway = interpolate_angle(math.pi - 0.1, -math.pi + 0.1, 0.5)
assert math.cos(halfway) == pytest.approx(-1.0)