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