proejct commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Testsuite der Physik-Sandbox."""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests der pygame-freien Werkzeug-Logik."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests der pygame-freien Physik-Schicht."""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user