172 lines
6.1 KiB
Python
172 lines
6.1 KiB
Python
"""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)
|