227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
"""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)
|