194 lines
6.6 KiB
Python
194 lines
6.6 KiB
Python
"""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)
|