proejct commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Headless-Tests für Oberfläche, Eingaben und Hauptschleife (SDL-Dummy-Treiber)."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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