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
+1
View File
@@ -0,0 +1 @@
"""Tests der pygame-freien Werkzeug-Logik."""
+29
View File
@@ -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
+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)
@@ -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)