Altstand nach old_version/ eingefroren (untracked). Monolith ersetzt durch main.py + Paket loader_game/. Neu: run.sh/run.command mit venv+requirements, .gitignore, docs/ANALYSE.md, 35 Tests. Secrets entfernt. Gelöscht: loader_game.py, qr_scan*.py, INSTALL_README.txt.

This commit is contained in:
Project Bridge committed 2026-11-01 10:25:13 +00:00
1 parent 6a2c891c3e
commit de550e1d4d
24 files changed
+2269 -1008

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
"""Tests für die Spiellogik (ohne Fenster, ohne Ton).
Aufruf::
.venv/bin/python -m unittest discover -s tests -v
"""
from __future__ import annotations
import os
import random
import tempfile
import unittest
from pathlib import Path
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
from loader_game import config, storage
from loader_game.case import Case
from loader_game.game import Game, State
from loader_game.input_map import Action
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
from loader_game.trailer import Trailer
class SequenceTests(unittest.TestCase):
def test_real_file_parses_into_six_sequences(self) -> None:
sequences, warnings = load_sequences()
self.assertEqual(warnings, [])
self.assertEqual(len(sequences), 6)
self.assertEqual(sequences[0][0], CaseSpec(3, 1))
self.assertEqual(sequences[0][3], CaseSpec(2, 1)) # aus "1,2,True"
def test_rotated_flag_swaps_dimensions(self) -> None:
sequences, _ = parse_sequences("1,3,True\n2,1,False")
self.assertEqual(sequences, [[CaseSpec(3, 1), CaseSpec(2, 1)]])
def test_blank_lines_separate_sequences(self) -> None:
sequences, _ = parse_sequences("1,1,False\n\n2,2,False\n\n")
self.assertEqual(len(sequences), 2)
def test_invalid_lines_warn_and_are_skipped(self) -> None:
sequences, warnings = parse_sequences("1,1\n1,1,False,extra\n0,1,False\n2,2,False")
self.assertEqual(sequences, [[CaseSpec(2, 2)]])
self.assertEqual(len(warnings), 3)
def test_garbage_falls_back_to_default(self) -> None:
sequences, warnings = parse_sequences("nur müll")
self.assertEqual(sequences, [])
# eine Meldung für die kaputte Zeile, eine für die fehlende Sequenz
self.assertEqual(len(warnings), 2)
def test_all_specs_fit_available_images(self) -> None:
sequences, _ = load_sequences()
for specs in sequences:
for spec in specs:
self.assertLessEqual(spec.units_w, config.MAX_UNITS_W, spec)
self.assertLessEqual(spec.units_h, config.MAX_UNITS_H, spec)
class LayoutTests(unittest.TestCase):
def test_bands_add_up_to_screen_height(self) -> None:
self.assertEqual(
config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT + config.BOTTOM_BAR_HEIGHT,
config.SCREEN_HEIGHT,
)
def test_trailer_fits_into_game_area(self) -> None:
self.assertGreaterEqual(config.TRAILER_X, 0)
self.assertLessEqual(config.TRAILER_RIGHT, config.SCREEN_WIDTH)
self.assertGreaterEqual(config.TRAILER_Y, config.TOP_BAR_HEIGHT)
self.assertLessEqual(
config.TRAILER_BOTTOM, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT
)
def test_case_unit_is_square(self) -> None:
self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT)
class CaseTests(unittest.TestCase):
def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case:
return Case(
units_w=units_w,
units_h=units_h,
x=float(config.SCREEN_WIDTH if x is None else x),
y=float(config.TRAILER_BOTTOM - units_h * config.UNIT_HEIGHT),
)
def test_moves_left_and_brakes_near_wall(self) -> None:
case = self.make_case()
start = case.x
case.move(1 / 60, wall_x=0)
fast_step = start - case.x
self.assertGreater(fast_step, 0)
case.x = float(config.FAST_TO_SLOW_DISTANCE - 20) # schon im Bremsbereich
before = case.x
case.move(1 / 60, wall_x=0)
slow_step = before - case.x
self.assertLess(slow_step, fast_step)
def test_allow_snap_only_near_wall(self) -> None:
case = self.make_case()
case.move(1 / 60, wall_x=0)
self.assertFalse(case.allow_snap)
case = self.make_case(x=config.SNAP_THRESHOLD + 2)
case.move(1 / 60, wall_x=0)
self.assertTrue(case.allow_snap)
def test_placed_case_does_not_move(self) -> None:
case = self.make_case()
case.placed = True
start = case.x
case.move(1 / 60, wall_x=0)
self.assertEqual(case.x, start)
def test_rotate_swaps_footprint_and_respects_trailer_height(self) -> None:
case = self.make_case(units_w=3, units_h=1)
self.assertTrue(case.fits_trailer_height())
case.rotate_footprint()
self.assertEqual((case.units_w, case.units_h), (1, 3))
wide = self.make_case(units_w=4, units_h=1)
self.assertFalse(wide.fits_trailer_height())
class TrailerTests(unittest.TestCase):
WALL = config.TRAILER_X + 500
def setUp(self) -> None:
self.trailer = Trailer()
def make_case(self, units_w: int, units_h: int) -> Case:
return Case(
units_w=units_w,
units_h=units_h,
x=float(config.TRAILER_RIGHT),
y=float(config.TRAILER_BOTTOM - units_h * config.UNIT_HEIGHT),
)
def add_base(self, units_w: int = 4, units_h: int = 1, y: float | None = None) -> Case:
"""Schwebender Koffer, rechtsbündig an der Barriere."""
base = Case(
units_w=units_w,
units_h=units_h,
x=0.0,
y=float(config.TRAILER_Y + 100 if y is None else y),
)
base.x = self.WALL - base.width
base.placed = True
base.snapped = True
self.trailer.stack.append(base)
self.trailer.wall_x = base.right
return base
# ------------------------------------------------------------------
def test_empty_trailer(self) -> None:
self.assertEqual(self.trailer.wall_x, self.trailer.x)
self.assertEqual(self.trailer.load_percent(), 0.0)
self.assertFalse(self.trailer.overflow())
self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1)))
def test_snap_advances_wall_and_load(self) -> None:
case = self.make_case(4, 1)
self.trailer.stack_at_wall(case)
self.assertTrue(case.placed)
self.assertTrue(case.snapped)
self.assertEqual(self.trailer.wall_x, self.trailer.x + case.width)
expected = (
case.width * case.height / (self.trailer.width * self.trailer.height) * 100
)
self.assertAlmostEqual(self.trailer.load_percent(), expected)
def test_gap_placement_leaves_hole(self) -> None:
case = self.make_case(2, 1)
self.trailer.stack_with_gap(case)
self.assertEqual(case.x, self.trailer.x + config.SNAP_THRESHOLD)
self.assertFalse(case.snapped)
def test_platform_on_flat_ground_case_is_allowed(self) -> None:
# Ein flacher Koffer am Boden hat noch Platz darüber -> darf Plattform sein.
ground = self.make_case(4, 1)
self.trailer.stack_at_wall(ground)
self.assertEqual(ground.y, config.TRAILER_Y + config.TRAILER_HEIGHT - ground.height)
self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), ground)
def test_no_platform_when_case_reaches_top(self) -> None:
# Ein 3 Einheiten hoher Koffer endet an der Oberkante -> kein Platz darüber.
ground = self.make_case(2, 3)
self.trailer.stack_at_wall(ground)
self.assertEqual(ground.y, config.TRAILER_Y)
self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1)))
def test_platform_found_for_free_top(self) -> None:
base = self.add_base(units_w=4, units_h=1)
self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), base)
def test_platform_rejects_too_wide_case(self) -> None:
self.add_base(units_w=2, units_h=1)
self.assertIsNone(self.trailer.find_platform(self.make_case(3, 1)))
def test_platform_rejects_case_not_aligned_with_wall(self) -> None:
base = self.add_base(units_w=4, units_h=1)
base.x -= 50 # nicht mehr bündig an der Barriere
self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1)))
def test_platform_rejects_no_space_above(self) -> None:
self.add_base(units_w=4, units_h=1, y=config.TRAILER_Y + 50)
self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1)))
def test_top_can_only_be_used_once(self) -> None:
base = self.add_base(units_w=4, units_h=1)
top = self.make_case(1, 1)
self.trailer.place_on_top(top, base)
self.assertEqual(top.bottom, base.y)
self.assertEqual(top.right, base.right)
self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1)))
def test_overflow(self) -> None:
self.trailer.wall_x = self.trailer.right + 1
self.assertTrue(self.trailer.overflow())
class GameTests(unittest.TestCase):
def make_game(self, sequence_length: int = 30) -> Game:
sequences = [[CaseSpec(2, 1)] * sequence_length]
return Game(None, sequences, name="Test", rng=random.Random(7), submit_scores=False)
def update_until(self, game: Game, predicate, steps: int = 5000) -> bool:
for _ in range(steps):
if predicate():
return True
game.update(1 / 60)
return predicate()
def test_starts_with_a_case(self) -> None:
game = self.make_game()
self.assertIsNotNone(game.current)
self.assertEqual(game.index, 1)
self.assertEqual(len(game.upcoming()), 3)
def test_snapping_places_case_and_advances_wall(self) -> None:
game = self.make_game()
self.assertTrue(
self.update_until(game, lambda: bool(game.current and game.current.allow_snap))
)
game.handle(Action.PRIMARY)
self.update_until(game, lambda: game.animation is None)
self.assertEqual(len(game.trailer.stack), 1)
self.assertAlmostEqual(
game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH
)
def test_rotation_turns_the_case(self) -> None:
game = self.make_game()
case = game.current
case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1)
case.allow_snap = True
game.handle(Action.LEFT)
self.update_until(game, lambda: game.animation is None)
self.assertEqual((case.units_w, case.units_h), (1, 2))
self.assertFalse(case.can_rotate)
def test_rotation_ignored_for_wide_cases(self) -> None:
game = Game(
None,
[[CaseSpec(4, 1)] * 5],
name="Test",
rng=random.Random(1),
submit_scores=False,
)
case = game.current
case.allow_snap = True
game.handle(Action.LEFT)
self.assertIsNone(game.animation)
self.assertEqual((case.units_w, case.units_h), (4, 1))
def test_missing_platform_stacks_with_gap(self) -> None:
game = self.make_game()
self.assertTrue(
self.update_until(game, lambda: bool(game.current and game.current.allow_snap))
)
game.handle(Action.UP) # es gibt nichts, worauf man stapeln könnte
self.assertEqual(len(game.trailer.stack), 1)
self.assertFalse(game.trailer.stack[0].snapped)
self.assertGreater(game.shake, 0)
def test_game_ends_when_trailer_overflows(self) -> None:
game = self.make_game(sequence_length=40)
self.assertTrue(
self.update_until(
game,
lambda: game.state is State.GAME_OVER,
steps=20000,
)
)
self.assertGreater(game.percent, 0)
self.assertLessEqual(game.percent, 100)
def test_game_ends_when_sequence_is_exhausted(self) -> None:
game = self.make_game(sequence_length=3)
for _ in range(20000):
if game.state is State.GAME_OVER:
break
if game.current is not None and game.current.allow_snap:
game.handle(Action.PRIMARY)
game.update(1 / 60)
self.assertIs(State.GAME_OVER, game.state)
self.assertEqual(len(game.trailer.stack), 3)
def test_back_quits_and_primary_confirms_game_over(self) -> None:
game = self.make_game(sequence_length=2)
self.assertTrue(game.handle(Action.PRIMARY))
self.assertFalse(game.handle(Action.BACK))
def test_game_over_lines_mention_player(self) -> None:
game = self.make_game(sequence_length=1)
game.percent = 80
lines = game.game_over_lines()
self.assertIn("Test", lines[0])
self.assertIn("80%", lines[0])
class StorageTests(unittest.TestCase):
def test_roundtrip_in_runtime_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["HAG_RUNTIME_DIR"] = tmp
try:
self.assertIsNone(storage.load_last_player())
storage.save_last_player(" Anna ")
self.assertEqual(storage.load_last_player(), "Anna")
self.assertTrue((Path(tmp) / storage.LAST_PLAYER_FILE).exists())
finally:
del os.environ["HAG_RUNTIME_DIR"]
def test_empty_name_is_ignored(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
os.environ["HAG_RUNTIME_DIR"] = tmp
try:
storage.save_last_player(" ")
self.assertIsNone(storage.load_last_player())
finally:
del os.environ["HAG_RUNTIME_DIR"]
if __name__ == "__main__":
unittest.main()