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 @@
"""Testsuite der Physik-Sandbox."""
+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)
+12
View File
@@ -0,0 +1,12 @@
"""Gemeinsame pytest-Konfiguration.
Die Umgebungsvariablen müssen gesetzt sein, *bevor* irgendein Test pygame importiert:
Mit dem Dummy-Treiber öffnet SDL kein echtes Fenster und braucht keine Soundkarte – so
laufen UI- und Integrationstests auch headless (z. B. in CI).
"""
import os
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
+81
View File
@@ -0,0 +1,81 @@
"""Hilfsfunktionen für die Physik-Tests (Szenen aufbauen, simulieren, Erhaltungsgrößen)."""
from __future__ import annotations
from dataclasses import replace
from sandbox.config import PHYSICS, TIME_STEP
from sandbox.physics import Body, Material, Vector2, World
WORLD_WIDTH = 1000.0
WORLD_HEIGHT = 700.0
def make_world(
width: float = WORLD_WIDTH, height: float = WORLD_HEIGHT, **overrides: object
) -> World:
"""Welt mit Standardkonfiguration; einzelne Parameter lassen sich überschreiben."""
return World(width, height, replace(PHYSICS, **overrides))
def material(density: float = 1.0, restitution: float = 0.5, friction: float = 0.5) -> Material:
"""Material mit gut lesbaren Standardwerten."""
return Material(density=density, restitution=restitution, friction=friction)
def add_circle(
world: World,
x: float,
y: float,
radius: float,
*,
velocity: tuple[float, float] = (0.0, 0.0),
angular_velocity: float = 0.0,
**material_values: float,
) -> Body:
"""Fügt einen Kreis hinzu."""
body = Body.circle(
Vector2(x, y), radius, material(**material_values), velocity=Vector2(*velocity)
)
body.angular_velocity = angular_velocity
return world.add_body(body)
def add_box(
world: World,
x: float,
y: float,
half_width: float,
half_height: float,
*,
velocity: tuple[float, float] = (0.0, 0.0),
**material_values: float,
) -> Body:
"""Fügt ein achsenparalleles Rechteck hinzu."""
body = Body.box(
Vector2(x, y),
half_width,
half_height,
material(**material_values),
velocity=Vector2(*velocity),
)
return world.add_body(body)
def simulate(world: World, seconds: float, dt: float = TIME_STEP) -> None:
"""Führt so viele feste Schritte aus, wie in ``seconds`` passen."""
for _ in range(round(seconds / dt)):
world.step(dt)
def total_momentum(bodies: list[Body]) -> Vector2:
"""Summe der Impulse m·v."""
total = Vector2()
for body in bodies:
total = total + body.momentum()
return total
def total_kinetic_energy(bodies: list[Body]) -> float:
"""Summe der Bewegungsenergien (translatorisch + rotatorisch)."""
return sum(body.kinetic_energy() for body in bodies)
+1
View File
@@ -0,0 +1 @@
"""Tests der pygame-freien Werkzeug-Logik."""
+29
View File
@@ -0,0 +1,29 @@
"""Tests der Beispielszene."""
import random
from sandbox.config import INTERACTION, TIME_STEP, DemoSceneConfig, Theme
from sandbox.demo import build_demo_scene
from sandbox.interaction import BodyFactory, ToolSettings
from tests.helpers import make_world
def test_demo_scene_fits_into_the_world_and_settles() -> None:
world = make_world(1020, 720)
factory = BodyFactory(world, INTERACTION, Theme().body_palette, random.Random(0))
scene = DemoSceneConfig()
build_demo_scene(factory, ToolSettings.from_config(INTERACTION), scene)
pyramid = scene.pyramid_rows * (scene.pyramid_rows + 1) // 2
assert len(world.bodies) == scene.tower_levels + pyramid + scene.ball_count
for body in world.bodies:
min_x, min_y, max_x, max_y = body.bounds()
assert min_x >= 0 and max_x <= world.width
assert min_y >= 0 and max_y <= world.height
for _ in range(round(4 / TIME_STEP)):
world.step(TIME_STEP)
tower = world.bodies[: scene.tower_levels]
assert all(body.sleeping for body in tower) # der Turm steht ruhig
tops = [body.position.y for body in tower]
assert tops == sorted(tops, reverse=True) # und ist nicht eingestürzt
+226
View File
@@ -0,0 +1,226 @@
"""Tests für Greifen/Ziehen/Werfen und das Erzeugen von Körpern (ohne pygame)."""
import math
import random
from dataclasses import replace
import pytest
from sandbox.config import INTERACTION, MIN_BODY_SIZE, PHYSICS, TIME_STEP, Theme
from sandbox.interaction import BodyFactory, BodyStyle, DragController, ShapeKind, ToolSettings
from sandbox.physics import Box, Circle, Vector2
from tests.helpers import add_box, add_circle, make_world
PALETTE = Theme().body_palette
def _factory(**overrides: object) -> BodyFactory:
return BodyFactory(make_world(), replace(INTERACTION, **overrides), PALETTE, random.Random(1))
def _settings(**overrides: object) -> ToolSettings:
return replace(ToolSettings.from_config(INTERACTION), **overrides)
# --------------------------------------------------------------------- Werkzeug-Einstellungen
def test_circle_half_extents_are_the_radius() -> None:
assert _settings(shape=ShapeKind.CIRCLE, size=30).half_extents() == (30, 30)
def test_box_aspect_ratio_changes_shape_but_not_area() -> None:
wide = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=2.0).half_extents()
square = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=1.0).half_extents()
assert wide[0] > wide[1]
assert square == pytest.approx((30, 30))
assert wide[0] * wide[1] == pytest.approx(square[0] * square[1])
def test_box_sides_never_drop_below_the_minimum_size() -> None:
thin = _settings(shape=ShapeKind.BOX, size=MIN_BODY_SIZE, aspect_ratio=2.0).half_extents()
assert min(thin) == pytest.approx(MIN_BODY_SIZE)
# --------------------------------------------------------------------- Körper erzeugen
def test_factory_creates_circles_and_boxes_with_the_selected_material() -> None:
factory = _factory()
circle = factory.create(_settings(size=20, density=2.0, restitution=0.9), Vector2(300, 300))
assert circle is not None
assert isinstance(circle.shape, Circle)
assert circle.mass == pytest.approx(math.pi * 20**2 * 2.0)
assert circle.material.restitution == 0.9
assert circle.material.friction == INTERACTION.friction
box = factory.create(_settings(shape=ShapeKind.BOX, size=20), Vector2(500, 300))
assert box is not None
assert isinstance(box.shape, Box)
assert factory.world.bodies == [circle, box]
def test_factory_moves_new_bodies_completely_into_the_world() -> None:
factory = _factory()
body = factory.create(_settings(size=30), Vector2(-100, 5000))
assert body is not None
assert body.position == Vector2(30, factory.world.height - 30)
def test_factory_respects_the_body_limit() -> None:
factory = _factory(max_bodies=2)
settings = _settings()
assert factory.create(settings, Vector2(100, 100)) is not None
assert factory.create(settings, Vector2(200, 100)) is not None
assert factory.is_full
assert factory.create(settings, Vector2(300, 100)) is None
assert len(factory.world.bodies) == 2
def test_preview_color_is_the_color_of_the_next_body() -> None:
factory = _factory()
settings = _settings(color_index=None)
for _ in range(10):
announced = factory.color_for(settings)
body = factory.create(settings, Vector2(300, 300))
assert body is not None
assert body.user_data == BodyStyle(announced)
def test_selected_palette_color_is_used() -> None:
factory = _factory()
body = factory.create(_settings(color_index=3), Vector2(300, 300))
assert body is not None
assert body.user_data == BodyStyle(PALETTE[3])
# --------------------------------------------------------------------- Ziehen und Werfen
def test_grab_marks_the_body_as_held_and_keeps_the_grip_offset() -> None:
world = make_world()
body = add_circle(world, 300, 300, 20)
body.put_to_sleep()
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(310, 295), time=0.0)
assert drag.body is body
assert body.held
assert not body.sleeping
drag.move(Vector2(410, 295), time=0.1)
assert drag.target == Vector2(400, 300) # Griffversatz (−10, +5) bleibt erhalten
def test_target_is_clamped_so_the_body_fits_into_the_world() -> None:
world = make_world()
body = add_circle(world, 300, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(300, 300), time=0.0)
drag.move(Vector2(-500, -500), time=0.1)
assert drag.target == Vector2(20, 20)
def test_held_body_follows_the_pointer_through_the_physics() -> None:
world = make_world()
body = add_circle(world, 300, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(300, 300), time=0.0)
drag.move(Vector2(450, 250), time=0.0)
for _ in range(60):
drag.apply(TIME_STEP)
world.step(TIME_STEP)
assert body.position.is_close(Vector2(450, 250), tolerance=0.5)
def test_held_body_speed_is_limited() -> None:
world = make_world()
body = add_circle(world, 30, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(30, 300), time=0.0)
drag.move(Vector2(980, 300), time=0.0)
drag.apply(TIME_STEP)
assert body.velocity.length() == pytest.approx(PHYSICS.max_speed)
def test_held_body_still_collides_and_pushes_other_bodies() -> None:
world = make_world()
obstacle = add_box(world, 500, 680, 20, 20)
held = add_box(world, 300, 680, 20, 20)
drag = DragController(world, INTERACTION)
drag.grab(held, Vector2(300, 680), time=0.0)
drag.move(Vector2(700, 680), time=0.0)
for _ in range(120):
drag.apply(TIME_STEP)
world.step(TIME_STEP)
assert obstacle.position.x > 520 # wurde weggeschoben
assert obstacle.position.x - held.position.x >= 40 - PHYSICS.penetration_slop - 1e-3
def test_held_body_pressed_against_an_obstacle_does_not_jitter() -> None:
"""Ein gegen ein blockiertes Hindernis gedrückter, gehaltener Körper steht ruhig.
Würde er abprallen, zöge ihn die Maus im nächsten Schritt wieder heran – er zitterte.
"""
world = make_world()
add_box(world, 1000 - 20, 680, 20, 20, restitution=1.0) # liegt an der rechten Wand
held = add_box(world, 700, 680, 20, 20, restitution=1.0)
drag = DragController(world, INTERACTION)
drag.grab(held, Vector2(700, 680), time=0.0)
drag.move(Vector2(990, 680), time=0.0) # Ziel liegt hinter dem Hindernis
positions = []
for step in range(180):
drag.apply(TIME_STEP)
world.step(TIME_STEP)
if step >= 120:
positions.append(held.position.x)
assert max(positions) - min(positions) < 0.1
# Wand → Hindernis → gehaltener Körper: Jeder der beiden Kontakte behält höchstens den
# Slop als Resttiefe, der Körper steht also bei x ≤ 1000 − 40 − 20 + 2·Slop.
assert max(positions) <= 1000 - 40 - 20 + 2 * PHYSICS.penetration_slop + 1e-3
def test_release_throws_with_the_smoothed_pointer_velocity() -> None:
world = make_world(gravity=(0.0, 0.0))
body = add_circle(world, 200, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(200, 300), time=0.0)
for frame in range(1, 31):
t = frame / 60
drag.move(Vector2(200 + 600 * t, 300 - 150 * t), time=t)
assert drag.release(paused=False) is body
assert body.velocity.is_close(Vector2(600, -150), tolerance=5.0)
assert not body.held
assert drag.body is None
def test_throw_speed_is_limited() -> None:
world = make_world()
body = add_circle(world, 200, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(200, 300), time=0.0)
for frame in range(1, 11):
drag.move(Vector2(200 + 100 * frame, 300), time=frame / 120) # 12 000 px/s
drag.release(paused=False)
assert body.velocity.length() == pytest.approx(INTERACTION.max_throw_speed)
def test_release_while_paused_does_not_throw() -> None:
world = make_world()
body = add_circle(world, 200, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(200, 300), time=0.0)
drag.move(Vector2(260, 300), time=0.05)
drag.place()
assert body.position == Vector2(260, 300)
drag.release(paused=True)
assert body.velocity == Vector2(0, 0)
def test_cancel_and_release_without_body_are_harmless() -> None:
world = make_world()
drag = DragController(world, INTERACTION)
drag.cancel()
drag.apply(TIME_STEP)
drag.place()
drag.move(Vector2(1, 1), time=0.0)
assert drag.release(paused=False) is None
assert drag.throw_velocity == Vector2(0, 0)
@@ -0,0 +1,75 @@
"""Tests der geglätteten Wurfgeschwindigkeit."""
import random
import pytest
from sandbox.interaction import VelocityTracker
from sandbox.physics import Vector2
WINDOW = 0.1
SMOOTHING = 0.05
def _track(velocity: Vector2, fps: float, seconds: float) -> VelocityTracker:
"""Bewegt einen Zeiger mit konstanter Geschwindigkeit und liefert den Tracker."""
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.reset(0.0, Vector2(0, 0))
frames = round(seconds * fps)
for frame in range(1, frames + 1):
t = frame / fps
tracker.add_sample(t, velocity * t)
return tracker
def test_constant_motion_is_measured_exactly() -> None:
tracker = _track(Vector2(600, -200), fps=60, seconds=0.5)
assert tracker.velocity.is_close(Vector2(600, -200), tolerance=1.0)
def test_result_does_not_depend_on_the_frame_rate() -> None:
slow = _track(Vector2(500, 0), fps=30, seconds=0.5)
fast = _track(Vector2(500, 0), fps=144, seconds=0.5)
assert slow.velocity.x == pytest.approx(fast.velocity.x, rel=0.01)
def test_velocity_decays_when_the_pointer_stops() -> None:
tracker = _track(Vector2(800, 0), fps=60, seconds=0.3)
resting = Vector2(800 * 0.3, 0)
for frame in range(1, 31): # eine halbe Sekunde stillhalten
tracker.add_sample(0.3 + frame / 60, resting)
if frame == 12: # nach 0,2 s ist der Schwung auf wenige Prozent abgeklungen
assert tracker.velocity.length() < 0.1 * 800
assert tracker.velocity.length() < 1.0
def test_jitter_is_smoothed() -> None:
rng = random.Random(0)
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.reset(0.0, Vector2(0, 0))
for frame in range(1, 61):
t = frame / 60
noise = Vector2(rng.uniform(-3, 3), rng.uniform(-3, 3)) # zittrige Hand
tracker.add_sample(t, Vector2(400 * t, 0) + noise)
assert tracker.velocity.x == pytest.approx(400, rel=0.1)
assert abs(tracker.velocity.y) < 60
def test_samples_with_the_same_timestamp_replace_each_other() -> None:
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.reset(0.0, Vector2(0, 0))
tracker.add_sample(0.0, Vector2(50, 0)) # gleicher Frame: keine Division durch 0
assert tracker.velocity == Vector2(0, 0)
tracker.add_sample(0.1, Vector2(50, 0))
assert tracker.velocity.x == 0 # die ersetzte Position ist der neue Bezugspunkt
def test_first_sample_without_reset_starts_a_measurement() -> None:
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.add_sample(1.0, Vector2(5, 5))
assert tracker.velocity == Vector2(0, 0)
def test_invalid_parameters_are_rejected() -> None:
with pytest.raises(ValueError, match="positiv"):
VelocityTracker(0, SMOOTHING)
+1
View File
@@ -0,0 +1 @@
"""Tests der pygame-freien Physik-Schicht."""
+171
View File
@@ -0,0 +1,171 @@
"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände)."""
import math
from collections.abc import Callable
import pytest
from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2
from sandbox.physics.vector import ZERO
from tests.helpers import material
def test_circle_area_and_moment_of_inertia() -> None:
circle = Circle(10)
assert circle.area() == pytest.approx(math.pi * 100)
assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100)
assert circle.half_extents() == (10, 10)
def test_box_area_and_infinite_inertia() -> None:
box = Box(3, 2)
assert box.area() == 24
assert math.isinf(box.moment_of_inertia(mass=5))
assert box.half_extents() == (3, 2)
@pytest.mark.parametrize(
"factory",
[lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)],
)
def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None:
with pytest.raises(ValueError, match="positiv"):
factory()
def test_half_plane_requires_unit_normal() -> None:
with pytest.raises(ValueError, match="Länge 1"):
HalfPlane(Vector2(0, 2), 0)
def test_half_plane_distance_and_contains() -> None:
floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100
assert floor.distance(Vector2(0, 90)) == pytest.approx(10)
assert floor.distance(Vector2(0, 110)) == pytest.approx(-10)
assert floor.contains(Vector2(0, 110))
assert not floor.contains(Vector2(0, 90))
assert floor.contains(Vector2(0, 90), tolerance=10)
assert math.isinf(floor.half_extents()[0])
def test_point_containment_with_tolerance() -> None:
circle = Body.circle(Vector2(100, 100), 10, material())
assert circle.contains_point(Vector2(107, 107))
assert not circle.contains_point(Vector2(110, 110))
assert circle.contains_point(Vector2(110, 110), tolerance=5)
box = Body.box(Vector2(0, 0), 10, 5, material())
assert box.contains_point(Vector2(10, -5))
assert not box.contains_point(Vector2(10.5, 0))
assert box.contains_point(Vector2(10.5, 0), tolerance=1)
def test_mass_is_area_times_density() -> None:
circle = Body.circle(Vector2(), 10, material(density=2))
assert circle.mass == pytest.approx(math.pi * 100 * 2)
assert circle.inv_mass == pytest.approx(1 / circle.mass)
box = Body.box(Vector2(), 10, 5, material(density=0.5))
assert box.mass == pytest.approx(20 * 10 * 0.5)
def test_bigger_bodies_are_heavier() -> None:
small = Body.circle(Vector2(), 10, material())
large = Body.circle(Vector2(), 20, material())
assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r²
def test_static_bodies_have_infinite_mass_and_never_move() -> None:
wall = Body.wall(Vector2(0, 1), 0, material())
assert wall.is_static
assert wall.inv_mass == 0
assert wall.inv_inertia == 0
assert math.isinf(wall.mass)
wall.apply_impulse(Vector2(100, 100))
assert wall.velocity == ZERO
assert wall.kinetic_energy() == 0
assert wall.momentum() == ZERO
def test_half_planes_cannot_be_dynamic() -> None:
with pytest.raises(ValueError, match="statisch"):
Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material())
def test_dynamic_bodies_need_positive_density() -> None:
with pytest.raises(ValueError, match="Dichte"):
Body.circle(Vector2(), 10, material(density=0))
@pytest.mark.parametrize(
("density", "restitution", "friction"),
[(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)],
)
def test_invalid_materials_are_rejected(
density: float, restitution: float, friction: float
) -> None:
with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"):
Material(density, restitution, friction)
def test_circles_can_rotate_but_boxes_cannot() -> None:
circle = Body.circle(Vector2(), 10, material())
box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5)
assert circle.can_rotate
assert not box.can_rotate
assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert
def test_off_center_impulse_spins_a_circle() -> None:
circle = Body.circle(Vector2(), 10, material())
# Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung.
circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10))
assert circle.velocity.is_close(Vector2(10, 0))
expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0))
assert circle.angular_velocity == pytest.approx(expected_spin)
assert circle.angular_velocity < 0
def test_off_center_impulse_does_not_spin_a_box() -> None:
box = Body.box(Vector2(), 10, 10, material())
box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10))
assert box.angular_velocity == 0
assert box.velocity.is_close(Vector2(1, 0))
def test_kinetic_energy_and_momentum() -> None:
circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4))
circle.angular_velocity = 2
expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4
assert circle.kinetic_energy() == pytest.approx(expected)
assert circle.momentum().is_close(Vector2(3, 4) * circle.mass)
def test_bounds() -> None:
assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25)
assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22)
def test_sleep_wake_and_teleport() -> None:
body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0))
body.angular_velocity = 1
body.sleep_time = 1
body.put_to_sleep()
assert body.sleeping
assert body.velocity == ZERO
assert body.angular_velocity == 0
body.teleport(Vector2(50, 60))
assert not body.sleeping
assert body.sleep_time == 0
assert body.position == body.previous_position == Vector2(50, 60)
def test_ids_are_unique() -> None:
bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)]
assert len({body.id for body in bodies}) == 5
def test_repr_mentions_id_and_sleep_state() -> None:
body = Body.circle(Vector2(1, 2), 10, material())
assert f"id={body.id}" in repr(body)
body.put_to_sleep()
assert "schläft" in repr(body)
+56
View File
@@ -0,0 +1,56 @@
"""Tests der Broadphase (Sweep-and-Prune)."""
import random
from sandbox.physics import Body, Vector2
from sandbox.physics.broadphase import find_candidate_pairs
from tests.helpers import material
def _random_scene(seed: int, count: int) -> list[Body]:
rng = random.Random(seed)
bodies = []
for _ in range(count):
position = Vector2(rng.uniform(0, 400), rng.uniform(0, 300))
if rng.random() < 0.5:
bodies.append(Body.circle(position, rng.uniform(5, 30), material()))
else:
bodies.append(Body.box(position, rng.uniform(5, 30), rng.uniform(5, 30), material()))
return bodies
def _overlap(a: Body, b: Body) -> bool:
a_min_x, a_min_y, a_max_x, a_max_y = a.bounds()
b_min_x, b_min_y, b_max_x, b_max_y = b.bounds()
return a_min_x <= b_max_x and b_min_x <= a_max_x and a_min_y <= b_max_y and b_min_y <= a_max_y
def test_matches_brute_force_on_random_scenes() -> None:
for seed in range(5):
bodies = _random_scene(seed, 60)
expected = {
(min(a.id, b.id), max(a.id, b.id))
for i, a in enumerate(bodies)
for b in bodies[i + 1 :]
if _overlap(a, b)
}
found = [(a.id, b.id) for a, b in find_candidate_pairs(bodies)]
assert len(found) == len(set(found)), "kein Paar doppelt"
assert set(found) == expected
def test_pairs_are_ordered_by_id() -> None:
bodies = _random_scene(7, 40)
for a, b in find_candidate_pairs(bodies):
assert a.id < b.id
def test_pairs_of_sleeping_bodies_are_skipped() -> None:
a = Body.circle(Vector2(0, 0), 10, material())
b = Body.circle(Vector2(15, 0), 10, material())
c = Body.circle(Vector2(30, 0), 10, material())
a.put_to_sleep()
b.put_to_sleep()
pairs = {(x.id, y.id) for x, y in find_candidate_pairs([a, b, c])}
assert (a.id, b.id) not in pairs # beide schlafen
assert (b.id, c.id) in pairs # schlafend + wach wird geprüft
+244
View File
@@ -0,0 +1,244 @@
"""Tests der Kollisionserkennung für alle Formpaarungen einschließlich Randfällen."""
import random
import pytest
from sandbox.physics import Body, Vector2, collide
from sandbox.physics.collision import (
COINCIDENT_NORMAL,
collide_box_half_plane,
collide_boxes,
collide_circle_box,
collide_circle_half_plane,
collide_circles,
)
from tests.helpers import material
def circle(x: float, y: float, radius: float) -> Body:
return Body.circle(Vector2(x, y), radius, material())
def box(x: float, y: float, half_width: float, half_height: float) -> Body:
return Body.box(Vector2(x, y), half_width, half_height, material())
def floor_at(y: float) -> Body:
return Body.wall(Vector2(0, -1), -y, material()) # frei ist alles oberhalb von y
# --------------------------------------------------------------------- Kreis–Kreis
def test_separated_circles_do_not_collide() -> None:
assert collide_circles(circle(0, 0, 10), circle(21, 0, 10)) is None
def test_touching_circles_form_a_contact_without_penetration() -> None:
contact = collide_circles(circle(0, 0, 10), circle(20, 0, 10))
assert contact is not None
assert contact.penetration == pytest.approx(0)
def test_overlapping_circles() -> None:
a = circle(0, 0, 10)
b = circle(15, 0, 10)
contact = collide_circles(a, b)
assert contact is not None
assert contact.body_a is a
assert contact.body_b is b
assert contact.normal.is_close(Vector2(1, 0))
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(7.5, 0)) # Mitte des Überlappungsbereichs
def test_coincident_circles_use_a_fixed_fallback_normal() -> None:
contact = collide_circles(circle(5, 5, 10), circle(5, 5, 8))
assert contact is not None
assert contact.normal == COINCIDENT_NORMAL
assert contact.penetration == pytest.approx(18)
# --------------------------------------------------------------------- Kreis–Rechteck
def test_circle_touching_the_top_face_of_a_box() -> None:
contact = collide_circle_box(circle(0, -14, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis (oben) zur Box (unten)
assert contact.penetration == pytest.approx(1)
assert contact.point.is_close(Vector2(0, -10))
def test_circle_touching_the_right_face_of_a_box() -> None:
contact = collide_circle_box(circle(24, 0, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0))
assert contact.penetration == pytest.approx(1)
def test_circle_touching_a_corner_gets_a_diagonal_normal() -> None:
contact = collide_circle_box(circle(23, -13, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 1).normalized())
assert contact.penetration == pytest.approx(5 - 18**0.5)
assert contact.point.is_close(Vector2(20, -10))
def test_circle_near_a_corner_but_outside_does_not_collide() -> None:
# Die umschließenden Boxen überlappen, der Kreis verfehlt die Ecke aber knapp.
assert collide_circle_box(circle(24, -14, 5), box(0, 0, 20, 10)) is None
def test_circle_center_inside_box_is_pushed_out_through_the_nearest_side_face() -> None:
contact = collide_circle_box(circle(15, 2, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0)) # Kreis bewegt sich entlang −n nach rechts
assert contact.penetration == pytest.approx(5 + 5) # Radius + Abstand zur Kante
assert contact.point.is_close(Vector2(20, 2))
def test_circle_center_inside_box_is_pushed_out_through_the_nearest_top_face() -> None:
contact = collide_circle_box(circle(3, -8, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # Kreis bewegt sich nach oben
assert contact.penetration == pytest.approx(5 + 2)
assert contact.point.is_close(Vector2(3, -10))
# --------------------------------------------------------------------- Rechteck–Rechteck
def test_boxes_are_separated_along_the_axis_of_least_overlap() -> None:
a = box(0, 0, 10, 10)
contact = collide_boxes(a, box(15, 5, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(1, 0))
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(7.5, 2.5)) # Mitte des Überlappungsrechtecks
contact = collide_boxes(a, box(5, -18, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, -1))
assert contact.penetration == pytest.approx(2)
def test_equal_overlap_prefers_the_vertical_axis() -> None:
contact = collide_boxes(box(0, 0, 10, 10), box(15, 15, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1))
@pytest.mark.parametrize(("x", "y"), [(21, 0), (0, 25), (-21, 5), (3, -20.5)])
def test_separated_boxes_do_not_collide(x: float, y: float) -> None:
assert collide_boxes(box(0, 0, 10, 10), box(x, y, 10, 10)) is None
# --------------------------------------------------------------------- Wände
def test_circle_resting_on_the_floor() -> None:
contact = collide_circle_half_plane(circle(50, 95, 10), floor_at(100))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis in die Wand
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(50, 100))
def test_circle_above_the_floor_does_not_collide() -> None:
assert collide_circle_half_plane(circle(50, 80, 10), floor_at(100)) is None
def test_circle_completely_behind_the_wall_is_still_detected() -> None:
# Der "Tunnel"-Fall: Selbst ein Körper hinter der Wandlinie wird erkannt
# und in den freien Raum zurückgeschoben.
contact = collide_circle_half_plane(circle(50, 130, 10), floor_at(100))
assert contact is not None
assert contact.penetration == pytest.approx(40)
assert contact.normal.is_close(Vector2(0, 1))
def test_box_against_floor_and_side_wall() -> None:
contact = collide_box_half_plane(box(50, 95, 20, 10), floor_at(100))
assert contact is not None
assert contact.penetration == pytest.approx(5)
left_wall = Body.wall(Vector2(1, 0), 0, material())
contact = collide_box_half_plane(box(15, 50, 20, 10), left_wall)
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0))
assert contact.penetration == pytest.approx(5)
assert collide_box_half_plane(box(25, 50, 20, 10), left_wall) is None
# --------------------------------------------------------------------- Zuordnung
def test_collide_orders_bodies_canonically() -> None:
c = circle(0, -14, 5)
b = box(0, 0, 20, 10)
for first, second in ((c, b), (b, c)):
contact = collide(first, second)
assert contact is not None
assert contact.body_a is c
assert contact.body_b is b
assert contact.normal.is_close(Vector2(0, 1))
wall = floor_at(100)
contact = collide(wall, circle(50, 95, 10))
assert contact is not None
assert contact.body_b is wall
def test_two_walls_never_collide() -> None:
assert collide(floor_at(100), floor_at(50)) is None
def test_contact_key_and_repr() -> None:
a = circle(0, 0, 10)
b = circle(15, 0, 10)
contact = collide(a, b)
assert contact is not None
assert contact.key == (a.id, b.id)
assert "penetration=5.000" in repr(contact)
# --------------------------------------------------------------------- Eigenschaften
def _random_body(rng: random.Random) -> Body:
x = rng.uniform(-40, 40)
y = rng.uniform(-40, 40)
if rng.random() < 0.5:
return circle(x, y, rng.uniform(5, 30))
return box(x, y, rng.uniform(5, 30), rng.uniform(5, 30))
def _random_wall(rng: random.Random) -> Body:
normal = rng.choice([Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)])
return Body.wall(normal, rng.uniform(-40, 40), material())
def test_random_contacts_are_consistent_and_resolvable() -> None:
"""Zufällige Kontakte sind in sich stimmig und lassen sich exakt auflösen.
Für viele Zufallspaare gilt: Die Normale ist ein Einheitsvektor, die Tiefe ist ≥ 0, und
verschiebt man Körper A um (Tiefe + ε) entgegen der Normalen, berühren sich die Körper
nicht mehr. Genau diese Information braucht die Kollisionsauflösung.
"""
rng = random.Random(1234)
checked = 0
for _ in range(3000):
first = _random_body(rng)
second = _random_wall(rng) if rng.random() < 0.25 else _random_body(rng)
contact = collide(first, second)
if contact is None:
continue
checked += 1
assert contact.normal.length() == pytest.approx(1)
assert contact.penetration >= 0
mover = contact.body_a
mover.position = mover.position - contact.normal * (contact.penetration + 1e-6)
assert collide(contact.body_a, contact.body_b) is None
assert checked > 500 # genug echte Kontakte geprüft
+105
View File
@@ -0,0 +1,105 @@
"""Tests des Schlafmodus (Inseln, Einschlafen, Aufwecken)."""
from dataclasses import replace
from sandbox.config import PHYSICS
from sandbox.physics import Body, Contact, Vector2, collide
from sandbox.physics.sleep import UnionFind, find_islands, update_sleep, wake_touched_bodies
from tests.helpers import material
def _circle(x: float, y: float = 0.0, radius: float = 10.0) -> Body:
return Body.circle(Vector2(x, y), radius, material())
def _contacts(*pairs: tuple[Body, Body]) -> list[Contact]:
contacts = [collide(a, b) for a, b in pairs]
assert all(contact is not None for contact in contacts)
return [contact for contact in contacts if contact is not None]
def test_union_find_merges_sets() -> None:
union_find = UnionFind([1, 2, 3, 4, 5])
union_find.union(1, 2)
union_find.union(3, 4)
union_find.union(2, 4)
assert union_find.find(1) == union_find.find(3) == union_find.find(4)
assert union_find.find(5) != union_find.find(1)
def test_touching_bodies_form_islands_but_walls_do_not_connect_them() -> None:
a, b, c, d = _circle(0), _circle(15), _circle(30), _circle(200)
floor = Body.wall(Vector2(0, -1), -5, material()) # berührt alle Kreise
contacts = _contacts((a, b), (b, c), (a, floor), (d, floor))
islands = find_islands([a, b, c, d], contacts)
assert [set(island) for island in islands] == [{a, b, c}, {d}]
def test_an_island_sleeps_only_when_all_its_bodies_rest() -> None:
config = replace(PHYSICS, time_to_sleep=0.5)
resting, moving = _circle(0), _circle(15)
moving.velocity = Vector2(50, 0)
contacts = _contacts((resting, moving))
for _ in range(100):
update_sleep([resting, moving], contacts, 0.01, config)
assert resting.sleep_time >= 0.5
assert not resting.sleeping # der bewegte Nachbar hält die Insel wach
moving.velocity = Vector2(0, 0)
for _ in range(60):
update_sleep([resting, moving], contacts, 0.01, config)
assert resting.sleeping
assert moving.sleeping
def test_waking_one_body_wakes_its_whole_island() -> None:
a, b, c, outsider = _circle(0), _circle(15), _circle(30), _circle(300)
island = [a, b, c]
for body in island:
body.put_to_sleep(island)
outsider.put_to_sleep()
c.wake()
assert not any(body.sleeping for body in island)
assert all(body.sleep_island is None for body in island)
assert outsider.sleeping
def test_touching_a_sleeper_wakes_and_reports_its_whole_island() -> None:
a, b, c = _circle(0), _circle(15), _circle(30)
island = [b, c]
for body in island:
body.put_to_sleep(island)
woken = wake_touched_bodies(_contacts((a, b)))
assert set(woken) == {b, c}
assert not c.sleeping
def test_held_bodies_keep_their_island_awake() -> None:
held, other = _circle(0), _circle(15)
held.held = True
contacts = _contacts((held, other))
for _ in range(200):
update_sleep([held, other], contacts, 0.01, PHYSICS)
assert not held.sleeping
assert not other.sleeping
assert held.sleep_time == 0
def test_sleepers_touched_by_awake_bodies_are_woken_but_not_by_walls() -> None:
sleeper, awake = _circle(0), _circle(15)
sleeper.put_to_sleep()
floor = Body.wall(Vector2(0, -1), -5, material())
lonely = _circle(300)
lonely.put_to_sleep()
both_asleep = _circle(500)
both_asleep.put_to_sleep()
neighbour = _circle(515)
neighbour.put_to_sleep()
woken = wake_touched_bodies(
_contacts((sleeper, awake), (lonely, floor), (both_asleep, neighbour))
)
assert woken == [sleeper]
assert not sleeper.sleeping
assert lonely.sleeping
assert both_asleep.sleeping
+186
View File
@@ -0,0 +1,186 @@
"""Tests der Kollisionsauflösung: Stöße, Restitution, Reibung, Rollen, Positionskorrektur.
Die Erwartungswerte stammen aus der klassischen Mechanik (Impuls- und Energieerhaltung,
Coulomb-Reibung, Rollbedingung v = ω·r) und nicht aus der Implementierung selbst.
"""
import math
import pytest
from sandbox.config import PHYSICS
from tests.helpers import (
add_box,
add_circle,
make_world,
simulate,
total_kinetic_energy,
total_momentum,
)
GRAVITY = PHYSICS.gravity[1]
FLOOR = 700.0
def _head_on_pair(restitution: float) -> tuple:
"""Zwei Kreise ohne Schwerkraft, Massenverhältnis 1:3, frontal aufeinander zu."""
world = make_world(gravity=(0.0, 0.0))
light = add_circle(world, 300, 350, 20, velocity=(200, 0), restitution=restitution)
heavy = add_circle(
world, 500, 350, 20, velocity=(-100, 0), density=3.0, restitution=restitution
)
return world, light, heavy
def test_elastic_collision_conserves_momentum_and_energy() -> None:
world, light, heavy = _head_on_pair(restitution=1.0)
momentum_before = total_momentum(world.bodies)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0) # Stoß bei t ≈ 0,53 s, danach noch weit weg von den Wänden
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
assert total_kinetic_energy(world.bodies) == pytest.approx(energy_before, rel=1e-9)
# Lehrbuchformel für den 1D-Stoß: v1' = ((m1 − m2)·v1 + 2·m2·v2) / (m1 + m2)
m1, m2, v1, v2 = light.mass, heavy.mass, 200.0, -100.0
assert light.velocity.x == pytest.approx(((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2))
assert heavy.velocity.x == pytest.approx(((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2))
assert light.velocity.x == pytest.approx(-250)
assert heavy.velocity.x == pytest.approx(50)
def test_equal_masses_exchange_velocities_in_an_elastic_collision() -> None:
world = make_world(gravity=(0.0, 0.0))
a = add_circle(world, 300, 350, 20, velocity=(150, 0), restitution=1.0)
b = add_circle(world, 400, 350, 20, restitution=1.0)
simulate(world, 0.8)
assert a.velocity.x == pytest.approx(0, abs=1e-9)
assert b.velocity.x == pytest.approx(150)
def test_perfectly_inelastic_collision_leaves_a_common_velocity() -> None:
world, light, heavy = _head_on_pair(restitution=0.0)
momentum_before = total_momentum(world.bodies)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0)
common = momentum_before.x / (light.mass + heavy.mass) # = −25 px/s
assert light.velocity.x == pytest.approx(common)
assert heavy.velocity.x == pytest.approx(common)
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
assert total_kinetic_energy(world.bodies) < energy_before
def test_restitution_below_one_loses_energy_as_predicted() -> None:
world, light, heavy = _head_on_pair(restitution=0.5)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0)
# Newtons Stoßgesetz: Trenngeschwindigkeit = e · Aufprallgeschwindigkeit (300 px/s).
assert heavy.velocity.x - light.velocity.x == pytest.approx(0.5 * 300)
# Energieverlust beim zentralen Stoß: ΔE = ½·μ·(1 − e²)·v_rel² mit μ = m1·m2/(m1 + m2).
reduced_mass = light.mass * heavy.mass / (light.mass + heavy.mass)
expected_loss = 0.5 * reduced_mass * (1 - 0.5**2) * 300**2
energy_loss = energy_before - total_kinetic_energy(world.bodies)
assert energy_loss == pytest.approx(expected_loss, rel=1e-6)
def test_slow_impacts_below_the_threshold_do_not_bounce() -> None:
world = make_world(gravity=(0.0, 0.0))
approach = 0.8 * PHYSICS.restitution_threshold
a = add_circle(world, 300, 350, 20, velocity=(approach, 0), restitution=1.0)
b = add_circle(world, 345, 350, 20, restitution=1.0)
simulate(world, 0.5)
assert b.velocity.x - a.velocity.x == pytest.approx(0, abs=1e-9)
def test_frictionless_oblique_impact_keeps_the_tangential_velocity() -> None:
world = make_world(gravity=(0.0, 0.0), wall_friction=0.0)
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), restitution=1.0, friction=0.0)
simulate(world, 0.4) # trifft die linke Wand bei t ≈ 0,27 s
assert ball.velocity.x == pytest.approx(300)
assert ball.velocity.y == pytest.approx(120)
assert ball.angular_velocity == 0 # Normalimpulse erzeugen kein Drehmoment
def test_friction_on_an_oblique_impact_makes_a_ball_spin() -> None:
world = make_world(gravity=(0.0, 0.0))
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), friction=0.8)
simulate(world, 0.4)
assert ball.angular_velocity != 0
assert abs(ball.velocity.y) < 120 # Reibung hat die Tangentialgeschwindigkeit gebremst
def test_sliding_box_decelerates_with_coulomb_friction() -> None:
world = make_world(wall_friction=0.6)
box = add_box(world, 200, FLOOR - 20, 20, 20, velocity=(300, 0), friction=0.4)
simulate(world, 1.5)
mu = math.sqrt(0.4 * 0.6) # gemischter Reibwert Box/Boden
expected_distance = 300**2 / (2 * mu * GRAVITY) # s = v² / (2·μ·g)
assert box.position.x - 200 == pytest.approx(expected_distance, rel=0.03)
assert box.velocity.x == pytest.approx(0, abs=1e-6)
def test_sliding_disk_starts_rolling_at_two_thirds_of_its_speed() -> None:
"""Eine rutschende Scheibe geht ins Rollen über und behält dabei ⅔ ihres Tempos.
Reibung bremst die Scheibe (I = ½·m·r²) und versetzt sie in Drehung, bis v = ω·r gilt.
Drehimpulserhaltung um den Kontaktpunkt liefert dann v = v0 / (1 + I/(m·r²)) = ⅔·v0 –
unabhängig vom Reibwert.
"""
world = make_world(rolling_resistance=0.0, sleep_enabled=False)
radius = 20
disk = add_circle(world, 200, FLOOR - radius, radius, velocity=(300, 0), friction=0.5)
simulate(world, 1.0)
assert disk.velocity.x == pytest.approx(200, rel=0.01)
assert disk.angular_velocity * radius == pytest.approx(disk.velocity.x, rel=0.01)
def test_rolling_resistance_decelerates_a_rolling_ball() -> None:
"""Rollwiderstand: Moment μ_r·N·r ⇒ Verzögerung a = μ_r·g / (1 + I/(m·r²)) = μ_r·g/1,5."""
world = make_world(sleep_enabled=False)
radius = 25
ball = add_circle(
world, 200, FLOOR - radius, radius, velocity=(200, 0), angular_velocity=200 / radius
)
simulate(world, 1.0)
expected_deceleration = PHYSICS.rolling_resistance * GRAVITY / 1.5
assert 200 - ball.velocity.x == pytest.approx(expected_deceleration, rel=0.02)
def test_resting_contact_carries_exactly_the_weight() -> None:
world = make_world(sleep_enabled=False)
box = add_box(world, 300, FLOOR - 20, 30, 20)
simulate(world, 0.5)
(contact,) = world.contacts
# Pro Schritt muss der Boden den Impuls m·g·dt aufbringen, damit die Box liegen bleibt.
assert contact.normal_impulse == pytest.approx(box.mass * GRAVITY * PHYSICS.time_step)
assert contact.tangent_impulse == pytest.approx(0, abs=1e-9)
def test_impulses_respect_non_penetration_and_coulomb_bounds() -> None:
world = make_world()
for i in range(12):
add_circle(world, 300 + 25 * (i % 4), 200 + 30 * (i // 4), 14, velocity=(40 * i, 0))
add_box(world, 600 + 30 * (i % 3), 150 + 35 * (i // 3), 14, 16)
for _ in range(240):
world.step(PHYSICS.time_step)
for contact in world.contacts:
assert contact.normal_impulse >= 0
mu = math.sqrt(contact.body_a.material.friction * contact.body_b.material.friction)
assert abs(contact.tangent_impulse) <= mu * contact.normal_impulse + 1e-9
def test_position_correction_separates_without_adding_velocity() -> None:
world = make_world(gravity=(0.0, 0.0), sleep_enabled=False)
a = add_circle(world, 500, 350, 20)
b = add_circle(world, 510, 350, 20) # 30 px Überlappung
simulate(world, 1.0)
assert a.position.distance_to(b.position) >= 40 - PHYSICS.penetration_slop - 1e-9
assert a.velocity.length() == 0
assert b.velocity.length() == 0
def test_warm_starting_can_be_disabled() -> None:
world = make_world(warm_starting=False)
box = add_box(world, 300, FLOOR - 20, 30, 20)
simulate(world, 2.0)
assert box.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
+57
View File
@@ -0,0 +1,57 @@
"""Tests des festen Zeitschritts (Akkumulator)."""
import pytest
from sandbox.physics import FixedTimestep
STEP = 1 / 120
def test_a_60_fps_frame_runs_two_physics_steps() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
assert timestep.advance(1 / 60) == 2
assert timestep.alpha == pytest.approx(0, abs=1e-6)
def test_short_frames_accumulate() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
assert timestep.advance(1 / 240) == 0
assert timestep.alpha == pytest.approx(0.5)
assert timestep.advance(1 / 240) == 1
def test_no_steps_are_lost_through_rounding() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
total = sum(timestep.advance(1 / 60) for _ in range(1000))
assert total == 2000
def test_alpha_stays_in_the_unit_interval() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
for frame in (0.003, 0.011, 0.017, 0.0001, 0.02):
timestep.advance(frame)
assert 0 <= timestep.alpha < 1
def test_huge_frames_are_capped_to_avoid_the_spiral_of_death() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=5)
assert timestep.advance(2.0) == 5
assert timestep.alpha == 0 # der Rückstand wurde verworfen
assert timestep.advance(1 / 60) == 2
def test_reset_discards_accumulated_time() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
timestep.advance(STEP * 0.9)
timestep.reset()
assert timestep.alpha == 0
assert timestep.advance(STEP * 0.5) == 0
def test_invalid_arguments_are_rejected() -> None:
with pytest.raises(ValueError, match="positiv"):
FixedTimestep(0, 8)
with pytest.raises(ValueError, match="mindestens"):
FixedTimestep(STEP, 0)
with pytest.raises(ValueError, match="negativ"):
FixedTimestep(STEP, 8).advance(-0.1)
+127
View File
@@ -0,0 +1,127 @@
"""Tests der eigenen Vektorklasse."""
import copy
import math
import pickle
import pytest
from sandbox.physics.vector import ZERO, Vector2
def test_addition_and_subtraction() -> None:
assert Vector2(1, 2) + Vector2(3, -5) == Vector2(4, -3)
assert Vector2(1, 2) - Vector2(3, -5) == Vector2(-2, 7)
def test_scaling_from_both_sides_and_division() -> None:
assert Vector2(1.5, -2) * 2 == Vector2(3, -4)
assert 2 * Vector2(1.5, -2) == Vector2(3, -4)
assert Vector2(3, -4) / 2 == Vector2(1.5, -2)
def test_negation() -> None:
assert -Vector2(1, -2) == Vector2(-1, 2)
def test_dot_product() -> None:
assert Vector2(1, 2).dot(Vector2(3, 4)) == 11
assert Vector2(1, 0).dot(Vector2(0, 5)) == 0 # orthogonal
def test_cross_product_sign_and_value() -> None:
assert Vector2(1, 0).cross(Vector2(0, 1)) == 1
assert Vector2(0, 1).cross(Vector2(1, 0)) == -1
assert Vector2(2, 3).cross(Vector2(4, 6)) == 0 # parallel
def test_length_and_length_squared() -> None:
v = Vector2(3, 4)
assert v.length() == 5
assert v.length_squared() == 25
def test_normalized_has_unit_length_and_same_direction() -> None:
n = Vector2(3, 4).normalized()
assert n.length() == pytest.approx(1.0)
assert n.is_close(Vector2(0.6, 0.8))
def test_normalizing_the_zero_vector_returns_zero_instead_of_nan() -> None:
assert Vector2(0, 0).normalized() == ZERO
def test_perpendicular_is_rotated_by_plus_90_degrees() -> None:
v = Vector2(2, 1)
p = v.perpendicular()
assert p == Vector2(-1, 2)
assert v.dot(p) == 0
assert v.cross(p) > 0
def test_distance() -> None:
assert Vector2(1, 1).distance_to(Vector2(4, 5)) == 5
def test_clamped_limits_only_long_vectors() -> None:
short = Vector2(1, 1)
assert short.clamped(10) is short
clamped = Vector2(30, 40).clamped(10)
assert clamped.length() == pytest.approx(10)
assert clamped.is_close(Vector2(6, 8))
def test_lerp() -> None:
a = Vector2(0, 10)
b = Vector2(10, 20)
assert a.lerp(b, 0) == a
assert a.lerp(b, 1) == b
assert a.lerp(b, 0.5) == Vector2(5, 15)
def test_is_finite() -> None:
assert Vector2(1, 2).is_finite()
assert not Vector2(math.nan, 0).is_finite()
assert not Vector2(0, math.inf).is_finite()
def test_is_close_uses_absolute_tolerance() -> None:
assert Vector2(1, 1).is_close(Vector2(1 + 1e-12, 1))
assert not Vector2(1, 1).is_close(Vector2(1.1, 1))
assert Vector2(1, 1).is_close(Vector2(1.1, 1), tolerance=0.2)
def test_equality_hash_and_comparison_with_other_types() -> None:
assert Vector2(1, 2) == Vector2(1.0, 2.0)
assert Vector2(1, 2) != Vector2(2, 1)
assert Vector2(1, 2) != (1, 2)
assert len({Vector2(1, 2), Vector2(1, 2), Vector2(2, 1)}) == 2
def test_vectors_are_immutable() -> None:
v = Vector2(1, 2)
with pytest.raises(AttributeError):
v.x = 5 # type: ignore[misc]
with pytest.raises(AttributeError):
del v.y
assert v == Vector2(1, 2)
def test_operations_do_not_modify_operands() -> None:
a = Vector2(1, 2)
b = Vector2(3, 4)
_ = (a + b, a - b, a * 3, a / 2, -a, a.normalized(), a.clamped(0.1), a.lerp(b, 0.5))
assert a == Vector2(1, 2)
assert b == Vector2(3, 4)
def test_copy_and_pickle_round_trip() -> None:
v = Vector2(1.5, -2.5)
assert copy.copy(v) == v
assert copy.deepcopy(v) == v
assert pickle.loads(pickle.dumps(v)) == v
def test_repr_and_tuple() -> None:
assert repr(Vector2(1.5, 2)) == "Vector2(1.5, 2)"
assert Vector2(1.5, 2).to_tuple() == (1.5, 2)
+446
View File
@@ -0,0 +1,446 @@
"""Szenario-Tests der Physikwelt: Fallen, Aufprallen, Stapeln, Ruhen, Grenzen, Stabilität."""
import itertools
import random
import pytest
from sandbox.config import MIN_BODY_SIZE, PHYSICS, TIME_STEP
from sandbox.physics import Body, Vector2, World
from tests.helpers import (
WORLD_HEIGHT,
WORLD_WIDTH,
add_box,
add_circle,
make_world,
material,
simulate,
total_kinetic_energy,
)
GRAVITY = PHYSICS.gravity[1]
FLOOR = WORLD_HEIGHT
#: Geduldete Rest-Überlappung ruhender Körper: die Toleranz ``penetration_slop`` plus
#: ein tausendstel Pixel für die nie ganz auskonvergierte Iteration.
MAX_RESTING_OVERLAP = PHYSICS.penetration_slop + 1e-3
# --------------------------------------------------------------------- Fallen und Aufprallen
def test_free_fall_matches_half_g_t_squared() -> None:
world = make_world(height=2000)
ball = add_circle(world, 500, 100, 10)
steps = 60
simulate(world, steps * TIME_STEP)
t = steps * TIME_STEP
fallen = ball.position.y - 100
assert fallen == pytest.approx(0.5 * GRAVITY * t * t, rel=0.02)
# Semi-impliziter Euler exakt: v_k = k·g·dt und s_n = g·dt²·n·(n+1)/2.
assert ball.velocity.y == pytest.approx(GRAVITY * t)
assert fallen == pytest.approx(GRAVITY * TIME_STEP**2 * steps * (steps + 1) / 2)
def test_bodies_without_gravity_stay_put() -> None:
world = make_world()
world.gravity_enabled = False
ball = add_circle(world, 500, 100, 10)
simulate(world, 1.0)
assert ball.position == Vector2(500, 100)
assert world.gravity == Vector2(0, 0)
def test_fallen_ball_comes_to_rest_on_the_floor_and_never_leaves_the_world() -> None:
world = make_world()
radius = 20
ball = add_circle(world, 500, 100, radius, restitution=0.6)
lowest = 0.0
for _ in range(round(8 / TIME_STEP)):
world.step(TIME_STEP)
lowest = max(lowest, ball.position.y + radius)
assert ball.position.x - radius >= 0
assert ball.position.x + radius <= WORLD_WIDTH
# Tiefer als die Notbremse erlaubt, sinkt der Ball nie in den Boden ein.
assert lowest <= FLOOR + PHYSICS.max_wall_penetration + 1e-9
assert ball.sleeping
assert ball.velocity == Vector2(0, 0)
assert ball.position.y == pytest.approx(FLOOR - radius, abs=PHYSICS.penetration_slop)
def _first_bounce_apex(restitution: float) -> tuple[float, float]:
"""Lässt einen Ball fallen und liefert (Fallhöhe, Steighöhe nach dem 1. Aufprall)."""
world = make_world()
radius = 20
start_y = 200.0
ball = add_circle(world, 500, start_y, radius, restitution=restitution)
resting_y = FLOOR - radius
bounced = False
apex = resting_y
for _ in range(round(4 / TIME_STEP)):
world.step(TIME_STEP)
if ball.velocity.y < 0:
bounced = True
apex = min(apex, ball.position.y)
elif bounced:
break # Scheitelpunkt überschritten
return resting_y - start_y, resting_y - apex
def test_bounce_height_follows_the_restitution_squared() -> None:
# Energieerhaltung beim Flug: h' / h = (v'/v)² = e².
drop, rise = _first_bounce_apex(restitution=0.8)
assert rise < drop # Energie geht verloren
assert rise == pytest.approx(0.8**2 * drop, rel=0.01)
def test_perfectly_elastic_ball_keeps_its_bounce_height() -> None:
"""Ein vollkommen elastischer Ball springt immer wieder genau auf seine Fallhöhe.
Er darf nie höher springen – sonst entstünde Energie aus dem Nichts, etwa durch die im
Aufprallschritt schon addierte Schwerkraft.
"""
world = make_world()
radius = 20
start_y = 250.0
ball = add_circle(world, 500, start_y, radius, restitution=1.0)
apexes = []
rising = False
for _ in range(round(8 / TIME_STEP)):
world.step(TIME_STEP)
if ball.velocity.y < 0:
rising = True
elif rising:
apexes.append(ball.position.y)
rising = False
assert len(apexes) >= 4
for apex in apexes:
assert apex == pytest.approx(start_y, abs=2.0) # 2 px bei 430 px Fallhöhe
def test_ball_without_restitution_does_not_bounce() -> None:
drop, rise = _first_bounce_apex(restitution=0.0)
assert drop > 0
assert rise == pytest.approx(0, abs=1.0)
def test_total_energy_never_increases_without_external_forces() -> None:
world = make_world(gravity=(0.0, 0.0))
rng = random.Random(42)
for _ in range(30):
velocity = (rng.uniform(-300, 300), rng.uniform(-300, 300))
x, y = rng.uniform(60, 940), rng.uniform(60, 640)
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 35), velocity=velocity)
else:
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35), velocity=velocity)
energy = total_kinetic_energy(world.bodies)
for _ in range(round(5 / TIME_STEP)):
world.step(TIME_STEP)
new_energy = total_kinetic_energy(world.bodies)
assert new_energy <= energy * (1 + 1e-9) + 1e-6
energy = new_energy
# --------------------------------------------------------------------- Überlappen und Stapeln
def test_overlapping_bodies_are_pushed_apart() -> None:
world = make_world()
bottom = add_box(world, 500, FLOOR - 30, 40, 30)
top = add_box(world, 510, FLOOR - 70, 30, 30) # steckt 20 px in der unteren Box
simulate(world, 2.0)
gap = (bottom.position.y - 30) - (top.position.y + 30)
assert gap >= -MAX_RESTING_OVERLAP
def test_circle_spawned_inside_a_box_pops_out() -> None:
world = make_world()
base = add_box(world, 500, FLOOR - 40, 60, 40)
ball = add_circle(world, 520, FLOOR - 50, 15) # Mittelpunkt liegt in der Box
simulate(world, 2.0)
assert ball.position.y + 15 <= base.position.y - 40 + MAX_RESTING_OVERLAP
def test_box_stack_is_stable_and_falls_asleep() -> None:
world = make_world()
half_height = 20
boxes = [
add_box(world, 500, FLOOR - half_height - i * 2 * half_height, 30, half_height)
for i in range(6)
]
simulate(world, 5.0)
for lower, upper in itertools.pairwise(boxes):
assert upper.position.x == pytest.approx(500, abs=1e-6) # kein seitliches Wandern
gap = (lower.position.y - half_height) - (upper.position.y + half_height)
assert -MAX_RESTING_OVERLAP <= gap <= 1e-9
assert all(box.sleeping for box in boxes)
def test_pile_of_mixed_bodies_settles_completely() -> None:
world = make_world()
rng = random.Random(3)
for _ in range(60):
x, y = rng.uniform(60, 940), rng.uniform(40, 500)
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 35))
else:
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35))
simulate(world, 8.0)
assert all(body.sleeping for body in world.bodies)
# --------------------------------------------------------------------- Geschwindigkeit und Tunneln
def test_speed_is_clamped_to_the_maximum() -> None:
world = make_world(gravity=(0.0, 0.0))
ball = add_circle(world, 500, 350, 20, velocity=(50_000, 0))
world.step(TIME_STEP)
assert ball.velocity.length() == pytest.approx(PHYSICS.max_speed)
def test_spin_is_clamped_to_the_maximum_in_both_directions() -> None:
world = make_world(gravity=(0.0, 0.0))
clockwise = add_circle(world, 300, 350, 20, angular_velocity=10_000)
counter = add_circle(world, 600, 350, 20, angular_velocity=-10_000)
world.step(TIME_STEP)
assert clockwise.angular_velocity == pytest.approx(PHYSICS.max_angular_speed)
assert counter.angular_velocity == pytest.approx(-PHYSICS.max_angular_speed)
def test_emergency_brake_never_interferes_with_free_flight() -> None:
# Die Notbremse muss über der größten Eindringtiefe pro Schritt liegen, sonst würde sie
# normale Aufpralle verfälschen (Positionen verschieben und Energie hinzufügen).
assert PHYSICS.max_wall_penetration > PHYSICS.max_speed * PHYSICS.time_step
def test_emergency_brake_limits_deep_wall_penetration() -> None:
world = make_world(gravity=(0.0, 0.0))
box = add_box(world, -10, 350, 20, 20) # steckt 30 px in der linken Wand
world.step(TIME_STEP)
assert box.bounds()[0] >= -PHYSICS.max_wall_penetration - 1e-9
simulate(world, 0.5)
assert box.bounds()[0] >= -MAX_RESTING_OVERLAP
def test_fast_small_ball_does_not_tunnel_through_a_wall() -> None:
world = make_world(gravity=(0.0, 0.0))
radius = MIN_BODY_SIZE
ball = add_circle(world, 500, 350, radius, velocity=(-50_000, -30_000), restitution=1.0)
for _ in range(round(3 / TIME_STEP)):
world.step(TIME_STEP)
allowed = PHYSICS.max_wall_penetration + 1e-9
assert -allowed <= ball.position.x - radius
assert ball.position.x + radius <= WORLD_WIDTH + allowed
assert -allowed <= ball.position.y - radius
assert ball.position.y + radius <= WORLD_HEIGHT + allowed
def test_two_smallest_bodies_at_max_speed_do_not_pass_through_each_other() -> None:
world = make_world(gravity=(0.0, 0.0))
radius = MIN_BODY_SIZE
speed = PHYSICS.max_speed
left = add_circle(world, 300, 350, radius, velocity=(speed, 0), restitution=1.0)
right = add_circle(world, 700, 350, radius, velocity=(-speed, 0), restitution=1.0)
simulate(world, 0.25) # Zusammenstoß nach ≈ 0,15 s
assert left.position.x < right.position.x
assert left.velocity.x < 0 < right.velocity.x
# --------------------------------------------------------------------- Schlafmodus
def test_sleeping_body_is_woken_by_an_impact() -> None:
world = make_world()
resting = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 1.5)
assert resting.sleeping
falling = add_circle(world, 500, 600, 15) # trifft die Box nach ≈ 0,3 s
simulate(world, 0.4)
assert not falling.sleeping
assert not resting.sleeping
def test_removing_a_support_wakes_the_body_on_top() -> None:
world = make_world()
support = add_box(world, 500, FLOOR - 20, 40, 20)
load = add_box(world, 500, FLOOR - 60, 30, 20)
simulate(world, 2.0)
assert support.sleeping
assert load.sleeping
world.remove_body(support)
assert not load.sleeping
simulate(world, 1.5)
assert load.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
def test_removing_a_body_only_wakes_sleeping_neighbours() -> None:
world = make_world()
removed = add_box(world, 300, FLOOR - 20, 40, 20)
neighbour = add_box(world, 360, FLOOR - 20, 20, 20)
far_away = add_box(world, 800, FLOOR - 20, 20, 20)
simulate(world, 2.0)
awake = add_circle(world, 300, 300, 15) # fällt noch, ist also wach
world.remove_body(removed)
assert not neighbour.sleeping
assert far_away.sleeping # weit weg: bleibt ungestört liegen
assert not awake.sleeping
assert removed not in world.bodies
def _is_supported(body: Body, world: World) -> bool:
"""Liegt der Körper auf dem Boden oder auf einem tiefer liegenden Körper auf?"""
min_x, _, max_x, max_y = body.bounds()
if max_y >= world.height - 1.0:
return True
for other in world.bodies:
if other is body or other.position.y <= body.position.y:
continue # nur Körper darunter können tragen
other_min_x, other_min_y, other_max_x, _ = other.bounds()
overlaps_horizontally = other_min_x <= max_x and min_x <= other_max_x
if overlaps_horizontally and other_min_y <= max_y + 1.0:
return True
return False
def test_moving_the_base_of_a_sleeping_stack_wakes_the_whole_stack() -> None:
"""Ein schlafender Stapel verliert seine Basis – die Kisten darüber fallen.
Die unterste Kiste wird (wie beim Ziehen im Pausenmodus) ohne Physikschritt weggesetzt.
Die Kisten darüber dürfen danach nicht in der Luft weiterschlafen.
"""
world = make_world()
stack = [add_box(world, 500, FLOOR - 20 - 40 * i, 30, 20) for i in range(3)]
simulate(world, 2.0)
assert all(box.sleeping for box in stack)
stack[0].teleport(Vector2(150, FLOOR - 20))
assert not any(box.sleeping for box in stack)
simulate(world, 2.0)
assert stack[1].position.y == pytest.approx(FLOOR - 20, abs=MAX_RESTING_OVERLAP)
assert stack[2].position.y == pytest.approx(FLOOR - 60, abs=2 * MAX_RESTING_OVERLAP)
def test_knocking_away_a_support_does_not_leave_bodies_floating() -> None:
"""Wird die Unterlage eines schlafenden Körpers weggestoßen, fällt er herunter.
Die flache Box verliert den Kontakt zur außermittig liegenden Kugel schon im ersten
Schritt nach dem Treffer. Ohne gemeinsames Wecken der Insel bliebe die Kugel schlafend
in der Luft hängen.
"""
world = make_world()
base = add_box(world, 500, FLOOR - 12, 40, 12) # flache Box am Boden
ball = add_circle(world, 530, FLOOR - 24 - 15, 15) # liegt 30 px außermittig darauf
simulate(world, 2.0)
assert base.sleeping
assert ball.sleeping
# Schwerer, flacher Rammbock trifft nur die Box, nicht die Kugel.
add_box(world, 800, FLOOR - 10, 20, 10, velocity=(-PHYSICS.max_speed, 0), density=5.0)
simulate(world, 2.0)
assert abs(ball.position.x - base.position.x) > 40 + 15 # Box ist unter der Kugel weg
assert ball.position.y == pytest.approx(FLOOR - 15, abs=MAX_RESTING_OVERLAP)
for body in world.bodies:
if body.sleeping:
assert _is_supported(body, world), f"{body} schläft in der Luft"
def test_toggling_gravity_wakes_everything() -> None:
world = make_world()
box = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 1.5)
assert box.sleeping
world.gravity_enabled = False
assert not box.sleeping
world.gravity_enabled = False # keine Änderung, kein erneutes Wecken nötig
assert not world.gravity_enabled
def test_held_bodies_ignore_gravity_and_never_sleep() -> None:
world = make_world()
ball = add_circle(world, 500, 300, 20)
ball.held = True
simulate(world, 2.0)
assert ball.position == Vector2(500, 300)
assert not ball.sleeping
def test_sleeping_can_be_disabled() -> None:
world = make_world(sleep_enabled=False)
box = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 3.0)
assert not box.sleeping
# --------------------------------------------------------------------- Verwaltung
def test_world_rejects_invalid_sizes_and_static_bodies() -> None:
with pytest.raises(ValueError, match="positive Größe"):
World(0, 100, PHYSICS)
world = make_world()
with pytest.raises(ValueError, match="Wände"):
world.add_body(Body.wall(Vector2(0, 1), 0, material()))
def test_body_at_returns_the_topmost_body() -> None:
world = make_world()
lower = add_box(world, 100, 100, 30, 30)
upper = add_circle(world, 110, 100, 20)
assert world.body_at(Vector2(110, 100)) is upper
assert world.body_at(Vector2(80, 80)) is lower
assert world.body_at(Vector2(300, 300)) is None
assert world.body_at(Vector2(133, 100), tolerance=5) is upper
def test_clamp_inside_keeps_the_whole_body_in_the_world() -> None:
world = make_world()
assert world.clamp_inside(Vector2(-50, 900), (20, 10)) == Vector2(20, WORLD_HEIGHT - 10)
inside = Vector2(300, 300)
assert world.clamp_inside(inside, (20, 10)) is inside
def test_clear_removes_all_bodies() -> None:
world = make_world()
for i in range(5):
add_circle(world, 100 + 50 * i, 600, 20)
simulate(world, 0.2)
world.clear()
assert world.bodies == []
assert world.contacts == []
simulate(world, 0.1) # auch eine leere Welt lässt sich weiter simulieren
def test_simulation_is_deterministic() -> None:
def run() -> list[tuple[float, float]]:
world = make_world()
rng = random.Random(5)
for _ in range(25):
add_circle(world, rng.uniform(50, 950), rng.uniform(50, 400), rng.uniform(12, 30))
simulate(world, 2.0)
return [body.position.to_tuple() for body in world.bodies]
assert run() == run()
@pytest.mark.slow
def test_hundred_bodies_stay_stable_for_a_thousand_steps() -> None:
world = make_world()
rng = random.Random(99)
for _ in range(100):
x, y = rng.uniform(40, 960), rng.uniform(40, 660)
velocity = (rng.uniform(-600, 600), rng.uniform(-600, 600))
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 30), velocity=velocity)
else:
add_box(world, x, y, rng.uniform(12, 30), rng.uniform(12, 30), velocity=velocity)
allowed = PHYSICS.max_wall_penetration + 1e-6
for _ in range(1000):
world.step(TIME_STEP)
for body in world.bodies:
assert body.position.is_finite()
assert body.velocity.is_finite()
assert body.velocity.length() <= PHYSICS.max_speed + 1e-6
min_x, min_y, max_x, max_y = body.bounds()
assert min_x >= -allowed and max_x <= WORLD_WIDTH + allowed
assert min_y >= -allowed and max_y <= WORLD_HEIGHT + allowed
+60
View File
@@ -0,0 +1,60 @@
"""Architektur-Tests: Die Physik-Schicht bleibt frei von pygame und fremden Bibliotheken.
Anforderung: Die gesamte Physik darf nur das Standardmodul ``math`` verwenden
(zusätzlich ``typing``/``dataclasses`` für Typangaben und Datenstrukturen). Diese Tests
prüfen das statisch über den Syntaxbaum und zur Laufzeit in einem frischen Interpreter.
"""
import ast
import os
import subprocess
import sys
from pathlib import Path
import pytest
SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "sandbox"
PHYSICS_FILES = sorted((SOURCE_ROOT / "physics").glob("*.py"))
PURE_FILES = [*PHYSICS_FILES, SOURCE_ROOT / "config.py"]
ALLOWED_STDLIB = {"__future__", "math", "typing", "dataclasses"}
ALLOWED_PROJECT = {"sandbox.config"}
def _imported_modules(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
modules.add(node.module or "")
return modules
@pytest.mark.parametrize("path", PURE_FILES, ids=lambda path: path.name)
def test_physics_and_config_import_only_allowed_modules(path: Path) -> None:
forbidden = {
module
for module in _imported_modules(path)
if module not in ALLOWED_STDLIB
and module not in ALLOWED_PROJECT
and not module.startswith("sandbox.physics")
}
assert not forbidden, f"{path.name} importiert nicht erlaubte Module: {forbidden}"
def test_physics_files_were_found() -> None:
assert len(PHYSICS_FILES) >= 8
def test_importing_the_physics_package_does_not_load_pygame() -> None:
code = "import sys, sandbox.physics, sandbox.config; print('pygame' in sys.modules)"
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT.parent)},
)
assert result.stdout.strip() == "False"