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