proejct commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests der pygame-freien Werkzeug-Logik."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests der Beispielszene."""
|
||||
|
||||
import random
|
||||
|
||||
from sandbox.config import INTERACTION, TIME_STEP, DemoSceneConfig, Theme
|
||||
from sandbox.demo import build_demo_scene
|
||||
from sandbox.interaction import BodyFactory, ToolSettings
|
||||
from tests.helpers import make_world
|
||||
|
||||
|
||||
def test_demo_scene_fits_into_the_world_and_settles() -> None:
|
||||
world = make_world(1020, 720)
|
||||
factory = BodyFactory(world, INTERACTION, Theme().body_palette, random.Random(0))
|
||||
scene = DemoSceneConfig()
|
||||
build_demo_scene(factory, ToolSettings.from_config(INTERACTION), scene)
|
||||
|
||||
pyramid = scene.pyramid_rows * (scene.pyramid_rows + 1) // 2
|
||||
assert len(world.bodies) == scene.tower_levels + pyramid + scene.ball_count
|
||||
for body in world.bodies:
|
||||
min_x, min_y, max_x, max_y = body.bounds()
|
||||
assert min_x >= 0 and max_x <= world.width
|
||||
assert min_y >= 0 and max_y <= world.height
|
||||
|
||||
for _ in range(round(4 / TIME_STEP)):
|
||||
world.step(TIME_STEP)
|
||||
tower = world.bodies[: scene.tower_levels]
|
||||
assert all(body.sleeping for body in tower) # der Turm steht ruhig
|
||||
tops = [body.position.y for body in tower]
|
||||
assert tops == sorted(tops, reverse=True) # und ist nicht eingestürzt
|
||||
@@ -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)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests der geglätteten Wurfgeschwindigkeit."""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from sandbox.interaction import VelocityTracker
|
||||
from sandbox.physics import Vector2
|
||||
|
||||
WINDOW = 0.1
|
||||
SMOOTHING = 0.05
|
||||
|
||||
|
||||
def _track(velocity: Vector2, fps: float, seconds: float) -> VelocityTracker:
|
||||
"""Bewegt einen Zeiger mit konstanter Geschwindigkeit und liefert den Tracker."""
|
||||
tracker = VelocityTracker(WINDOW, SMOOTHING)
|
||||
tracker.reset(0.0, Vector2(0, 0))
|
||||
frames = round(seconds * fps)
|
||||
for frame in range(1, frames + 1):
|
||||
t = frame / fps
|
||||
tracker.add_sample(t, velocity * t)
|
||||
return tracker
|
||||
|
||||
|
||||
def test_constant_motion_is_measured_exactly() -> None:
|
||||
tracker = _track(Vector2(600, -200), fps=60, seconds=0.5)
|
||||
assert tracker.velocity.is_close(Vector2(600, -200), tolerance=1.0)
|
||||
|
||||
|
||||
def test_result_does_not_depend_on_the_frame_rate() -> None:
|
||||
slow = _track(Vector2(500, 0), fps=30, seconds=0.5)
|
||||
fast = _track(Vector2(500, 0), fps=144, seconds=0.5)
|
||||
assert slow.velocity.x == pytest.approx(fast.velocity.x, rel=0.01)
|
||||
|
||||
|
||||
def test_velocity_decays_when_the_pointer_stops() -> None:
|
||||
tracker = _track(Vector2(800, 0), fps=60, seconds=0.3)
|
||||
resting = Vector2(800 * 0.3, 0)
|
||||
for frame in range(1, 31): # eine halbe Sekunde stillhalten
|
||||
tracker.add_sample(0.3 + frame / 60, resting)
|
||||
if frame == 12: # nach 0,2 s ist der Schwung auf wenige Prozent abgeklungen
|
||||
assert tracker.velocity.length() < 0.1 * 800
|
||||
assert tracker.velocity.length() < 1.0
|
||||
|
||||
|
||||
def test_jitter_is_smoothed() -> None:
|
||||
rng = random.Random(0)
|
||||
tracker = VelocityTracker(WINDOW, SMOOTHING)
|
||||
tracker.reset(0.0, Vector2(0, 0))
|
||||
for frame in range(1, 61):
|
||||
t = frame / 60
|
||||
noise = Vector2(rng.uniform(-3, 3), rng.uniform(-3, 3)) # zittrige Hand
|
||||
tracker.add_sample(t, Vector2(400 * t, 0) + noise)
|
||||
assert tracker.velocity.x == pytest.approx(400, rel=0.1)
|
||||
assert abs(tracker.velocity.y) < 60
|
||||
|
||||
|
||||
def test_samples_with_the_same_timestamp_replace_each_other() -> None:
|
||||
tracker = VelocityTracker(WINDOW, SMOOTHING)
|
||||
tracker.reset(0.0, Vector2(0, 0))
|
||||
tracker.add_sample(0.0, Vector2(50, 0)) # gleicher Frame: keine Division durch 0
|
||||
assert tracker.velocity == Vector2(0, 0)
|
||||
tracker.add_sample(0.1, Vector2(50, 0))
|
||||
assert tracker.velocity.x == 0 # die ersetzte Position ist der neue Bezugspunkt
|
||||
|
||||
|
||||
def test_first_sample_without_reset_starts_a_measurement() -> None:
|
||||
tracker = VelocityTracker(WINDOW, SMOOTHING)
|
||||
tracker.add_sample(1.0, Vector2(5, 5))
|
||||
assert tracker.velocity == Vector2(0, 0)
|
||||
|
||||
|
||||
def test_invalid_parameters_are_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="positiv"):
|
||||
VelocityTracker(0, SMOOTHING)
|
||||
Reference in New Issue
Block a user