Files
2026-09-24 15:45:31 +02:00

333 lines
11 KiB
Python

"""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()