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
+171
View File
@@ -0,0 +1,171 @@
"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände)."""
import math
from collections.abc import Callable
import pytest
from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2
from sandbox.physics.vector import ZERO
from tests.helpers import material
def test_circle_area_and_moment_of_inertia() -> None:
circle = Circle(10)
assert circle.area() == pytest.approx(math.pi * 100)
assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100)
assert circle.half_extents() == (10, 10)
def test_box_area_and_infinite_inertia() -> None:
box = Box(3, 2)
assert box.area() == 24
assert math.isinf(box.moment_of_inertia(mass=5))
assert box.half_extents() == (3, 2)
@pytest.mark.parametrize(
"factory",
[lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)],
)
def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None:
with pytest.raises(ValueError, match="positiv"):
factory()
def test_half_plane_requires_unit_normal() -> None:
with pytest.raises(ValueError, match="Länge 1"):
HalfPlane(Vector2(0, 2), 0)
def test_half_plane_distance_and_contains() -> None:
floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100
assert floor.distance(Vector2(0, 90)) == pytest.approx(10)
assert floor.distance(Vector2(0, 110)) == pytest.approx(-10)
assert floor.contains(Vector2(0, 110))
assert not floor.contains(Vector2(0, 90))
assert floor.contains(Vector2(0, 90), tolerance=10)
assert math.isinf(floor.half_extents()[0])
def test_point_containment_with_tolerance() -> None:
circle = Body.circle(Vector2(100, 100), 10, material())
assert circle.contains_point(Vector2(107, 107))
assert not circle.contains_point(Vector2(110, 110))
assert circle.contains_point(Vector2(110, 110), tolerance=5)
box = Body.box(Vector2(0, 0), 10, 5, material())
assert box.contains_point(Vector2(10, -5))
assert not box.contains_point(Vector2(10.5, 0))
assert box.contains_point(Vector2(10.5, 0), tolerance=1)
def test_mass_is_area_times_density() -> None:
circle = Body.circle(Vector2(), 10, material(density=2))
assert circle.mass == pytest.approx(math.pi * 100 * 2)
assert circle.inv_mass == pytest.approx(1 / circle.mass)
box = Body.box(Vector2(), 10, 5, material(density=0.5))
assert box.mass == pytest.approx(20 * 10 * 0.5)
def test_bigger_bodies_are_heavier() -> None:
small = Body.circle(Vector2(), 10, material())
large = Body.circle(Vector2(), 20, material())
assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r²
def test_static_bodies_have_infinite_mass_and_never_move() -> None:
wall = Body.wall(Vector2(0, 1), 0, material())
assert wall.is_static
assert wall.inv_mass == 0
assert wall.inv_inertia == 0
assert math.isinf(wall.mass)
wall.apply_impulse(Vector2(100, 100))
assert wall.velocity == ZERO
assert wall.kinetic_energy() == 0
assert wall.momentum() == ZERO
def test_half_planes_cannot_be_dynamic() -> None:
with pytest.raises(ValueError, match="statisch"):
Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material())
def test_dynamic_bodies_need_positive_density() -> None:
with pytest.raises(ValueError, match="Dichte"):
Body.circle(Vector2(), 10, material(density=0))
@pytest.mark.parametrize(
("density", "restitution", "friction"),
[(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)],
)
def test_invalid_materials_are_rejected(
density: float, restitution: float, friction: float
) -> None:
with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"):
Material(density, restitution, friction)
def test_circles_can_rotate_but_boxes_cannot() -> None:
circle = Body.circle(Vector2(), 10, material())
box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5)
assert circle.can_rotate
assert not box.can_rotate
assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert
def test_off_center_impulse_spins_a_circle() -> None:
circle = Body.circle(Vector2(), 10, material())
# Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung.
circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10))
assert circle.velocity.is_close(Vector2(10, 0))
expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0))
assert circle.angular_velocity == pytest.approx(expected_spin)
assert circle.angular_velocity < 0
def test_off_center_impulse_does_not_spin_a_box() -> None:
box = Body.box(Vector2(), 10, 10, material())
box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10))
assert box.angular_velocity == 0
assert box.velocity.is_close(Vector2(1, 0))
def test_kinetic_energy_and_momentum() -> None:
circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4))
circle.angular_velocity = 2
expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4
assert circle.kinetic_energy() == pytest.approx(expected)
assert circle.momentum().is_close(Vector2(3, 4) * circle.mass)
def test_bounds() -> None:
assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25)
assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22)
def test_sleep_wake_and_teleport() -> None:
body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0))
body.angular_velocity = 1
body.sleep_time = 1
body.put_to_sleep()
assert body.sleeping
assert body.velocity == ZERO
assert body.angular_velocity == 0
body.teleport(Vector2(50, 60))
assert not body.sleeping
assert body.sleep_time == 0
assert body.position == body.previous_position == Vector2(50, 60)
def test_ids_are_unique() -> None:
bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)]
assert len({body.id for body in bodies}) == 5
def test_repr_mentions_id_and_sleep_state() -> None:
body = Body.circle(Vector2(1, 2), 10, material())
assert f"id={body.id}" in repr(body)
body.put_to_sleep()
assert "schläft" in repr(body)