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
+1
View File
@@ -0,0 +1 @@
"""Headless-Tests für Oberfläche, Eingaben und Hauptschleife (SDL-Dummy-Treiber)."""
+18
View File
@@ -0,0 +1,18 @@
"""Fixture für die headless bediente Anwendung."""
import random
from collections.abc import Iterator
import pygame
import pytest
from sandbox.main import App
@pytest.fixture
def app() -> Iterator[App]:
"""Frische Anwendung mit reproduzierbaren Zufallsfarben."""
application = App(rng=random.Random(0))
pygame.event.clear()
yield application
application.close()
+68
View File
@@ -0,0 +1,68 @@
"""Hilfsfunktionen, um die Anwendung headless per Event zu bedienen.
Die Tests posten echte pygame-Events in die Event-Queue und rufen dann
:meth:`sandbox.main.App.frame` auf – genau wie die Hauptschleife. So wird die komplette
Kette Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen durchlaufen.
"""
import pygame
from sandbox.main import App
from sandbox.physics import Vector2
#: Dauer eines Test-Frames (60 Bilder/s → je zwei Physikschritte).
FRAME = 1 / 60
def screen_pos(app: App, x: float, y: float) -> tuple[int, int]:
"""Bildschirmposition eines Weltpunkts (auf ganze Pixel gerundet)."""
sx, sy = app.viewport.to_screen(Vector2(x, y))
return (round(sx), round(sy))
def frames(app: App, count: int = 1) -> None:
"""Lässt die Anwendung einige Frames laufen."""
for _ in range(count):
app.frame(FRAME)
def post(event_type: int, **attributes: object) -> None:
"""Legt ein Event in die pygame-Queue."""
pygame.event.post(pygame.event.Event(event_type, **attributes))
def press(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None:
"""Maustaste an ``pos`` drücken (ein Frame)."""
post(pygame.MOUSEBUTTONDOWN, pos=pos, button=button)
frames(app)
def move(app: App, pos: tuple[int, int]) -> None:
"""Maus nach ``pos`` bewegen (ein Frame)."""
post(pygame.MOUSEMOTION, pos=pos, rel=(0, 0), buttons=(1, 0, 0))
frames(app)
def release(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None:
"""Maustaste an ``pos`` loslassen (ein Frame)."""
post(pygame.MOUSEBUTTONUP, pos=pos, button=button)
frames(app)
def click(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None:
"""Klick (Drücken und Loslassen) an ``pos``."""
press(app, pos, button)
release(app, pos, button)
def key(app: App, name: str) -> None:
"""Taste drücken (Name wie in ``pygame.key.key_code``)."""
post(pygame.KEYDOWN, key=pygame.key.key_code(name), mod=0, unicode="", scancode=0)
frames(app)
def wheel(app: App, pos: tuple[int, int], steps: int) -> None:
"""Mausrad über ``pos`` drehen."""
move(app, pos)
post(pygame.MOUSEWHEEL, x=0, y=steps, flipped=False)
frames(app)
+332
View File
@@ -0,0 +1,332 @@
"""Integrationstests: Die Anwendung wird headless über echte pygame-Events bedient."""
from dataclasses import replace
from pathlib import Path
import pygame
import pytest
from sandbox.config import APP, INTERACTION, PHYSICS
from sandbox.interaction import ShapeKind
from sandbox.main import App, main
from sandbox.physics import Vector2
from sandbox.ui.widgets import ColorPicker, SegmentedControl, Slider
from tests.app.driver import click, frames, key, move, post, press, release, screen_pos, wheel
# --------------------------------------------------------------------- Erstellen
def test_click_into_the_world_creates_a_body(app: App) -> None:
click(app, screen_pos(app, 400, 300))
(body,) = app.world.bodies
assert body.position.is_close(Vector2(400, 300), tolerance=1.0)
assert body.shape.radius == app.tools.size
assert not body.held
def test_created_body_falls_and_lands_on_the_floor(app: App) -> None:
click(app, screen_pos(app, 400, 300))
frames(app, 240) # Elastizität 0,5: nach ≈ 2,6 s sind alle Rücksprünge abgeklungen
(body,) = app.world.bodies
floor = app.world.height - app.tools.size
assert body.position.y == pytest.approx(floor, abs=PHYSICS.penetration_slop + 1e-3)
def test_sidebar_settings_apply_to_new_bodies(app: App) -> None:
shape = app.sidebar.widget("shape")
colors = app.sidebar.widget("color")
assert isinstance(shape, SegmentedControl)
assert isinstance(colors, ColorPicker)
click(app, shape.segment_rects()[1].center) # "Rechteck"
swatch_index, swatch = colors.swatches()[3]
click(app, swatch.center)
click(app, screen_pos(app, 400, 300))
(body,) = app.world.bodies
assert app.tools.shape is ShapeKind.BOX
assert body.shape.half_extents() == pytest.approx(app.tools.half_extents())
assert body.user_data.color == app.config.theme.body_palette[swatch_index]
def test_drag_from_the_sidebar_creates_a_body_when_entering_the_world(app: App) -> None:
tile = app.sidebar.widget("spawn_tile").rect
press(app, tile.center)
move(app, (tile.right - 5, tile.centery)) # noch über der Sidebar
assert app.world.bodies == []
target = screen_pos(app, 200, 250)
move(app, target)
(body,) = app.world.bodies
assert body.held
release(app, target)
assert not body.held
def test_drag_from_the_sidebar_is_cancelled_when_released_over_the_sidebar(app: App) -> None:
tile = app.sidebar.widget("spawn_tile").rect
press(app, tile.center)
move(app, (tile.centerx, tile.top - 20))
release(app, (tile.centerx, tile.top - 20))
frames(app, 3)
assert app.world.bodies == []
assert not app.input.spawn_drag_active
def test_body_limit_is_respected(app: App) -> None:
app.close()
limited = App(replace(APP, interaction=replace(INTERACTION, max_bodies=2)))
try:
for x in (200, 400, 600):
click(limited, screen_pos(limited, x, 300))
assert len(limited.world.bodies) == 2
assert "(voll)" in limited.status_lines()[0]
finally:
limited.close()
# --------------------------------------------------------------------- Greifen, Ziehen, Werfen
def _resting_body(app: App, x: float) -> tuple[int, int]:
"""Erzeugt einen Körper, lässt ihn landen und liefert seine Bildschirmposition."""
click(app, screen_pos(app, x, 600))
frames(app, 90)
body = app.world.bodies[-1]
return screen_pos(app, body.position.x, body.position.y)
def test_grabbed_body_follows_the_mouse(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
for step in range(1, 21):
move(app, (start[0] + 5 * step, start[1] - 10 * step))
frames(app, 10)
expected = app.viewport.to_world((start[0] + 100, start[1] - 200))
assert body.position.is_close(expected, tolerance=1.0)
release(app, (start[0] + 100, start[1] - 200))
def test_release_throws_the_body_with_the_mouse_velocity(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
x, y = start
for _ in range(20): # 10 px pro Frame bei 60 Bildern/s = 600 px/s nach rechts
x += 10
move(app, (x, y - 150))
release(app, (x, y - 150))
assert body.velocity.x == pytest.approx(600, rel=0.05)
frames(app, 10)
assert body.position.x > app.viewport.to_world((x, y)).x # fliegt weiter
def test_holding_still_before_release_does_not_throw(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
target = (start[0] + 200, start[1] - 150)
for step in range(1, 11):
move(app, (start[0] + 20 * step, start[1] - 15 * step))
frames(app, 30) # eine halbe Sekunde stillhalten
release(app, target)
assert abs(body.velocity.x) < 1
def test_dragged_body_pushes_other_bodies(app: App) -> None:
obstacle_pos = _resting_body(app, 300)
obstacle = app.world.bodies[-1]
start = _resting_body(app, 600)
press(app, start)
held = app.drag.body
assert held is not None and held is not obstacle
for step in range(1, 31): # nach links durch das Hindernis hindurch ziehen
move(app, (start[0] - 15 * step, start[1]))
assert obstacle.position.x < app.viewport.to_world(obstacle_pos).x - 50
overlap = (
held.shape.radius + obstacle.shape.radius - held.position.distance_to(obstacle.position)
)
assert overlap < PHYSICS.max_position_correction
release(app, (start[0] - 450, start[1]))
def test_dragged_body_cannot_leave_the_world(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
move(app, (5, 5)) # über die Sidebar hinaus
frames(app, 30)
radius = body.shape.radius
assert body.position.x >= radius - PHYSICS.max_wall_penetration
assert body.position.y >= radius - PHYSICS.max_wall_penetration
release(app, (5, 5))
def test_paused_drag_places_the_body_without_throwing(app: App) -> None:
start = _resting_body(app, 300)
key(app, "space")
assert app.paused
# Versatz zwischen Griffpunkt (ganze Pixel) und Schwerpunkt, gemessen vor dem Greifen.
grip_offset = app.world.bodies[-1].position - app.viewport.to_world(start)
press(app, start)
body = app.drag.body
assert body is app.world.bodies[-1]
target = (start[0] + 120, start[1] - 200)
move(app, target)
expected = app.viewport.to_world(target) + grip_offset
assert body.position.is_close(expected, tolerance=1e-6)
release(app, target)
assert body.velocity == Vector2(0, 0)
frames(app, 10)
assert body.position.is_close(expected, tolerance=1e-6) # bleibt: Simulation pausiert
# --------------------------------------------------------------------- Löschen
def test_right_click_deletes_a_body(app: App) -> None:
pos = _resting_body(app, 300)
click(app, pos, pygame.BUTTON_RIGHT)
assert app.world.bodies == []
def test_delete_key_removes_the_body_under_the_mouse(app: App) -> None:
pos = _resting_body(app, 300)
move(app, pos)
assert app.input.hovered_body is app.world.bodies[0]
key(app, "delete")
assert app.world.bodies == []
def test_right_click_on_the_held_body_releases_and_deletes_it(app: App) -> None:
pos = _resting_body(app, 300)
press(app, pos)
press(app, pos, pygame.BUTTON_RIGHT)
assert app.world.bodies == []
assert app.drag.body is None
release(app, pos)
def test_clear_button_removes_everything(app: App) -> None:
for x in (200, 400, 600):
click(app, screen_pos(app, x, 300))
click(app, app.sidebar.widget("clear").rect.center)
assert app.world.bodies == []
# --------------------------------------------------------------------- Sidebar und Tastatur
def test_pause_button_freezes_and_resumes_the_simulation(app: App) -> None:
click(app, screen_pos(app, 400, 100))
body = app.world.bodies[0]
pause = app.sidebar.widget("pause").rect.center
click(app, pause)
assert app.paused
frozen = body.position
frames(app, 30)
assert body.position == frozen
click(app, pause)
frames(app, 10)
assert body.position.y > frozen.y
def test_gravity_button_toggles_gravity(app: App) -> None:
click(app, app.sidebar.widget("gravity").rect.center)
assert not app.world.gravity_enabled
click(app, screen_pos(app, 400, 100))
frames(app, 30)
assert app.world.bodies[0].position.is_close(Vector2(400, 100), tolerance=1e-6)
def test_button_only_triggers_when_released_over_it(app: App) -> None:
button = app.sidebar.widget("pause").rect
press(app, button.center)
release(app, (button.centerx, button.bottom + 40))
assert not app.paused
def test_size_slider_can_be_clicked_dragged_and_scrolled(app: App) -> None:
slider = app.sidebar.widget("size")
assert isinstance(slider, Slider)
press(app, (slider.rect.right - 1, slider.track_y))
assert app.tools.size == INTERACTION.max_size
move(app, (slider.rect.left - 50, slider.track_y)) # Ziehen darf die Sidebar verlassen
assert app.tools.size == INTERACTION.min_size
release(app, (slider.rect.left - 50, slider.track_y))
wheel(app, slider.rect.center, 3)
assert app.tools.size == INTERACTION.min_size + 3 * INTERACTION.size_step
def test_aspect_slider_is_disabled_for_circles(app: App) -> None:
slider = app.sidebar.widget("aspect_ratio")
assert isinstance(slider, Slider)
before = app.tools.aspect_ratio
click(app, (slider.rect.right - 1, slider.track_y))
assert app.tools.aspect_ratio == before
key(app, "2")
click(app, (slider.rect.right - 1, slider.track_y))
assert app.tools.aspect_ratio == INTERACTION.max_aspect_ratio
def test_mouse_wheel_over_the_world_changes_the_size(app: App) -> None:
before = app.tools.size
wheel(app, screen_pos(app, 400, 300), 2)
assert app.tools.size == before + 2 * INTERACTION.size_wheel_step
wheel(app, screen_pos(app, 400, 300), -1000)
assert app.tools.size == INTERACTION.min_size
def test_keyboard_shortcuts(app: App) -> None:
key(app, "space")
assert app.paused
key(app, "space")
assert not app.paused
key(app, "g")
assert not app.world.gravity_enabled
key(app, "2")
assert app.tools.shape is ShapeKind.BOX
key(app, "1")
assert app.tools.shape is ShapeKind.CIRCLE
key(app, "f1")
assert app.debug
click(app, screen_pos(app, 400, 300))
key(app, "c")
assert app.world.bodies == []
key(app, "x") # nicht belegt: keine Wirkung
key(app, "escape")
assert not app.running
def test_closing_the_window_stops_the_main_loop(app: App) -> None:
post(pygame.QUIT)
frames(app)
assert not app.running
# --------------------------------------------------------------------- Darstellung und Start
def test_debug_view_and_demo_scene_render_without_errors(app: App) -> None:
app.load_demo_scene()
key(app, "f1")
frames(app, 30)
lines = app._debug_lines()
assert lines[0].startswith("Körper: 26")
move(app, screen_pos(app, 100, 100)) # Vorschau über leerer Welt
assert app.input.spawn_preview() is not None
def test_screenshot_is_written(app: App, tmp_path: Path) -> None:
path = tmp_path / "shot.png"
frames(app)
app.save_screenshot(path)
assert path.stat().st_size > 0
def test_command_line_screenshot_mode(tmp_path: Path) -> None:
path = tmp_path / "cli.png"
assert main(["--demo", "--screenshot", str(path), "--seconds", "0.2"]) == 0
assert path.exists()
@@ -0,0 +1,111 @@
"""Einstiegspunkte (``python -m sandbox``, CLI) und Randfälle der Eingabeverarbeitung."""
import runpy
import sys
from dataclasses import replace
from pathlib import Path
import pygame
import pytest
from sandbox import main as main_module
from sandbox.config import APP, INTERACTION, Theme, UiConfig
from sandbox.main import App, FrameStats, main
from sandbox.ui.style import UiStyle, _load_font
from tests.app.driver import click, frames, key, move, post, press, release, screen_pos
def test_python_dash_m_starts_the_app(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
screenshot = tmp_path / "module.png"
monkeypatch.setattr(
sys, "argv", ["sandbox", "--screenshot", str(screenshot), "--seconds", "0.1"]
)
with pytest.raises(SystemExit) as exit_info:
runpy.run_module("sandbox", run_name="__main__")
assert exit_info.value.code == 0
assert screenshot.exists()
def test_main_without_options_runs_the_interactive_loop(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[float | None] = []
monkeypatch.setattr(
main_module.App, "run", lambda self, max_seconds=None: calls.append(max_seconds)
)
assert main([]) == 0
assert calls == [None]
def test_main_loop_ends_when_the_window_is_closed(app: App) -> None:
post(pygame.QUIT)
app.run() # kehrt nach dem ersten Frame zurück
assert not app.running
def test_frame_stats_are_smoothed_and_ignore_empty_frames() -> None:
stats = FrameStats(smoothing=0.5)
stats.update(1 / 50, 0.002)
assert stats.fps == pytest.approx(50)
stats.update(1 / 100, 0.004)
assert stats.fps == pytest.approx(75)
stats.update(0.0, 0.0) # z. B. allererster Frame: keine Division durch 0
assert stats.fps == pytest.approx(75)
assert stats.physics_ms > 0
def test_clicks_outside_the_world_without_sidebar_are_ignored(app: App) -> None:
app.input.sidebar = None # InputHandler auch ohne Sidebar nutzbar
click(app, (10, 10)) # links neben der Welt
assert app.world.bodies == []
click(app, screen_pos(app, 400, 300))
assert len(app.world.bodies) == 1
def test_middle_click_and_delete_without_target_do_nothing(app: App) -> None:
click(app, screen_pos(app, 400, 300), pygame.BUTTON_MIDDLE)
assert app.world.bodies == []
move(app, screen_pos(app, 600, 300))
key(app, "delete") # nichts unter der Maus
click(app, screen_pos(app, 600, 300), pygame.BUTTON_RIGHT)
assert app.world.bodies == []
def test_spawn_drag_is_ignored_when_the_world_is_full(app: App) -> None:
app.close()
full = App(replace(APP, interaction=replace(INTERACTION, max_bodies=1)))
try:
click(full, screen_pos(full, 400, 300))
tile = full.sidebar.widget("spawn_tile").rect
press(full, tile.center)
move(full, screen_pos(full, 200, 200))
assert len(full.world.bodies) == 1
assert full.drag.body is None
assert full.input.spawn_preview() is None # voll: keine Vorschau mehr
release(full, screen_pos(full, 200, 200))
frames(full)
finally:
full.close()
def test_unknown_widget_names_raise(app: App) -> None:
with pytest.raises(KeyError):
app.sidebar.widget("gibt-es-nicht")
def test_missing_system_fonts_fall_back_to_the_builtin_font() -> None:
pygame.font.init()
try:
font = _load_font(("keine-solche-schrift-xyz",), 14, bold=True)
assert font.bold
assert font.size("Test")[0] > 0
finally:
pygame.font.quit()
def test_style_initializes_the_font_module_itself() -> None:
pygame.font.quit()
style = UiStyle(Theme(), UiConfig())
try:
assert pygame.font.get_init()
assert style.render_text("x", style.label_font, (0, 0, 0)).get_width() > 0
finally:
pygame.font.quit()
+77
View File
@@ -0,0 +1,77 @@
"""Smoke-Test: Die echte Hauptschleife läuft einige Sekunden headless mit Zufallseingaben.
Ein simulierter, zufällig klickender "Affe" bedient dabei alles, was ein Mensch auch
bedienen könnte: Klicks in Welt und Sidebar, Ziehen und Werfen, Rechtsklicks, Mausrad
und Tastenkürzel. Der Test gilt als bestanden, wenn keine Exception auftritt und die
Simulation danach in einem gültigen Zustand ist.
"""
import random
import pygame
import pytest
from sandbox.config import PHYSICS
from sandbox.main import App
SMOKE_SECONDS = 3.0
SAFE_KEYS = ("space", "g", "c", "delete", "1", "2", "f1") # alles außer "Beenden"
class MonkeyApp(App):
"""Die echte Anwendung, die sich vor jedem Frame selbst zufällige Events schickt."""
def __init__(self, seed: int) -> None:
super().__init__(rng=random.Random(seed))
self.monkey = random.Random(seed)
self.frame_count = 0
def frame(self, frame_time: float) -> None:
self._post_random_events()
super().frame(frame_time)
self.frame_count += 1
def _random_position(self) -> tuple[int, int]:
return (
self.monkey.randrange(self.config.window.width),
self.monkey.randrange(self.config.window.height),
)
def _post_random_events(self) -> None:
roll = self.monkey.random()
position = self._random_position()
if roll < 0.25:
button = self.monkey.choice([pygame.BUTTON_LEFT, pygame.BUTTON_RIGHT])
pygame.event.post(
pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=position, button=button)
)
elif roll < 0.45:
pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=position, button=1))
elif roll < 0.85:
pygame.event.post(
pygame.event.Event(pygame.MOUSEMOTION, pos=position, rel=(0, 0), buttons=(1, 0, 0))
)
elif roll < 0.93:
pygame.event.post(
pygame.event.Event(pygame.MOUSEWHEEL, x=0, y=self.monkey.choice([-1, 1]))
)
else:
key = pygame.key.key_code(self.monkey.choice(SAFE_KEYS))
pygame.event.post(
pygame.event.Event(pygame.KEYDOWN, key=key, mod=0, unicode="", scancode=0)
)
@pytest.mark.slow
def test_main_loop_survives_seconds_of_random_input() -> None:
app = MonkeyApp(seed=7)
try:
app.load_demo_scene()
app.run(max_seconds=SMOKE_SECONDS)
assert app.running
assert app.frame_count > 0
for body in app.world.bodies:
assert body.position.is_finite()
assert body.velocity.length() <= PHYSICS.max_speed + 1e-6
finally:
app.close()
+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)