proejct commit
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user