proejct commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user