proejct commit

This commit is contained in:
2026-09-24 15:45:31 +02:00
commit 82d52be8a5
63 changed files with 8748 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
"""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)