v1.1: Stufen Easy/Normal/Hard mit Auswahlmenü (Hard = altes Tempo). Koffer behalten beim Drehen ihre Textur. Name/Punkteliste entfernt, nur Stufe + Prozent. Endlos: Ergebnis -> "Nochmals?" -> Stufenauswahl, Ende nur per Esc. storage.py und net.py gelöscht, 48 Tests.
This commit is contained in:
1 parent
de550e1d4d
commit
d970185e75
15 files changed
+613
-342
No files matched your search
+180
-55
@@ -9,17 +9,17 @@ 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 import config
|
||||
from loader_game.case import Case
|
||||
from loader_game.game import Game, State
|
||||
from loader_game.difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, by_key
|
||||
from loader_game.game import Game, HandleResult, State
|
||||
from loader_game.input_map import Action
|
||||
from loader_game.menu import DifficultyMenu
|
||||
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
|
||||
from loader_game.trailer import Trailer
|
||||
|
||||
@@ -78,6 +78,25 @@ class LayoutTests(unittest.TestCase):
|
||||
self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT)
|
||||
|
||||
|
||||
class DifficultyTests(unittest.TestCase):
|
||||
def test_three_levels_in_order(self) -> None:
|
||||
self.assertEqual([d.key for d in DIFFICULTIES], ["easy", "normal", "hard"])
|
||||
self.assertEqual([d.label for d in DIFFICULTIES], ["Easy", "Normal", "Hard"])
|
||||
|
||||
def test_hard_matches_old_game_speed(self) -> None:
|
||||
hard = by_key("hard")
|
||||
self.assertEqual((hard.speed_fast, hard.speed_slow), (720.0, 240.0))
|
||||
|
||||
def test_speeds_increase_with_difficulty(self) -> None:
|
||||
fast = [d.speed_fast for d in DIFFICULTIES]
|
||||
slow = [d.speed_slow for d in DIFFICULTIES]
|
||||
self.assertEqual(fast, sorted(fast))
|
||||
self.assertEqual(slow, sorted(slow))
|
||||
|
||||
def test_unknown_key_falls_back_to_default(self) -> None:
|
||||
self.assertIs(by_key("gibtsnicht"), DEFAULT_DIFFICULTY)
|
||||
|
||||
|
||||
class CaseTests(unittest.TestCase):
|
||||
def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case:
|
||||
return Case(
|
||||
@@ -100,6 +119,13 @@ class CaseTests(unittest.TestCase):
|
||||
slow_step = before - case.x
|
||||
self.assertLess(slow_step, fast_step)
|
||||
|
||||
def test_difficulty_scales_speed(self) -> None:
|
||||
easy = self.make_case()
|
||||
hard = self.make_case()
|
||||
easy.move(0.1, wall_x=0, difficulty=DIFFICULTIES[0])
|
||||
hard.move(0.1, wall_x=0, difficulty=DIFFICULTIES[2])
|
||||
self.assertGreater(easy.x, hard.x) # easy ist langsamer, bleibt weiter rechts
|
||||
|
||||
def test_allow_snap_only_near_wall(self) -> None:
|
||||
case = self.make_case()
|
||||
case.move(1 / 60, wall_x=0)
|
||||
@@ -116,14 +142,19 @@ class CaseTests(unittest.TestCase):
|
||||
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))
|
||||
def test_case_keeps_its_texture_when_rotated(self) -> None:
|
||||
case = self.make_case(units_w=1, units_h=2)
|
||||
self.assertEqual(case.texture, (1, 2))
|
||||
self.assertFalse(case.is_rotated)
|
||||
|
||||
wide = self.make_case(units_w=4, units_h=1)
|
||||
self.assertFalse(wide.fits_trailer_height())
|
||||
case.rotate_footprint()
|
||||
self.assertEqual((case.units_w, case.units_h), (2, 1))
|
||||
self.assertEqual(case.texture, (1, 2)) # Textur bleibt
|
||||
self.assertTrue(case.is_rotated)
|
||||
|
||||
def test_rotate_respects_trailer_height(self) -> None:
|
||||
self.assertTrue(self.make_case(units_w=3, units_h=1).fits_trailer_height())
|
||||
self.assertFalse(self.make_case(units_w=4, units_h=1).fits_trailer_height())
|
||||
|
||||
|
||||
class TrailerTests(unittest.TestCase):
|
||||
@@ -183,11 +214,10 @@ class TrailerTests(unittest.TestCase):
|
||||
# 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.
|
||||
# 3 Einheiten hoch = Oberkante des Aufliegers -> kein Platz darüber.
|
||||
ground = self.make_case(2, 3)
|
||||
self.trailer.stack_at_wall(ground)
|
||||
self.assertEqual(ground.y, config.TRAILER_Y)
|
||||
@@ -223,10 +253,40 @@ class TrailerTests(unittest.TestCase):
|
||||
self.assertTrue(self.trailer.overflow())
|
||||
|
||||
|
||||
class MenuTests(unittest.TestCase):
|
||||
def test_navigation_wraps_around(self) -> None:
|
||||
menu = DifficultyMenu(None, selected=DIFFICULTIES[1])
|
||||
self.assertIs(menu.selected, DIFFICULTIES[1])
|
||||
|
||||
self.assertIsNone(menu.handle(Action.LEFT))
|
||||
self.assertIs(menu.selected, DIFFICULTIES[0])
|
||||
|
||||
menu.handle(Action.LEFT) # Umlauf ans Ende
|
||||
self.assertIs(menu.selected, DIFFICULTIES[-1])
|
||||
|
||||
menu.handle(Action.RIGHT) # Umlauf an den Anfang
|
||||
self.assertIs(menu.selected, DIFFICULTIES[0])
|
||||
|
||||
def test_confirm_returns_selection(self) -> None:
|
||||
menu = DifficultyMenu(None, selected=DIFFICULTIES[2])
|
||||
self.assertIs(menu.handle(Action.PRIMARY), DIFFICULTIES[2])
|
||||
|
||||
def test_other_actions_do_not_select(self) -> None:
|
||||
menu = DifficultyMenu(None)
|
||||
self.assertIsNone(menu.handle(Action.UP))
|
||||
self.assertIsNone(menu.handle(Action.DOWN))
|
||||
self.assertIsNone(menu.handle(Action.BACK))
|
||||
|
||||
|
||||
class GameTests(unittest.TestCase):
|
||||
def make_game(self, sequence_length: int = 30) -> Game:
|
||||
def make_game(self, sequence_length: int = 30, difficulty=None) -> Game:
|
||||
sequences = [[CaseSpec(2, 1)] * sequence_length]
|
||||
return Game(None, sequences, name="Test", rng=random.Random(7), submit_scores=False)
|
||||
return Game(
|
||||
None,
|
||||
sequences,
|
||||
difficulty=difficulty or DIFFICULTIES[2],
|
||||
rng=random.Random(7),
|
||||
)
|
||||
|
||||
def update_until(self, game: Game, predicate, steps: int = 5000) -> bool:
|
||||
for _ in range(steps):
|
||||
@@ -253,23 +313,29 @@ class GameTests(unittest.TestCase):
|
||||
game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH
|
||||
)
|
||||
|
||||
def test_rotation_turns_the_case(self) -> None:
|
||||
game = self.make_game()
|
||||
def test_rotation_keeps_texture(self) -> None:
|
||||
game = Game(
|
||||
None,
|
||||
[[CaseSpec(1, 2)] * 5],
|
||||
difficulty=DIFFICULTIES[2],
|
||||
rng=random.Random(1),
|
||||
)
|
||||
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.assertEqual((case.units_w, case.units_h), (2, 1))
|
||||
self.assertEqual(case.texture, (1, 2))
|
||||
self.assertTrue(case.is_rotated)
|
||||
self.assertFalse(case.can_rotate)
|
||||
|
||||
def test_rotation_ignored_for_wide_cases(self) -> None:
|
||||
game = Game(
|
||||
None,
|
||||
[[CaseSpec(4, 1)] * 5],
|
||||
name="Test",
|
||||
difficulty=DIFFICULTIES[2],
|
||||
rng=random.Random(1),
|
||||
submit_scores=False,
|
||||
)
|
||||
case = game.current
|
||||
case.allow_snap = True
|
||||
@@ -287,14 +353,18 @@ class GameTests(unittest.TestCase):
|
||||
self.assertFalse(game.trailer.stack[0].snapped)
|
||||
self.assertGreater(game.shake, 0)
|
||||
|
||||
def test_easy_is_slower_than_hard(self) -> None:
|
||||
easy = self.make_game(difficulty=DIFFICULTIES[0])
|
||||
hard = self.make_game(difficulty=DIFFICULTIES[2])
|
||||
for _ in range(30):
|
||||
easy.update(1 / 60)
|
||||
hard.update(1 / 60)
|
||||
self.assertGreater(easy.current.x, hard.current.x)
|
||||
|
||||
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.update_until(game, lambda: game.state is State.FINISHED, steps=20000)
|
||||
)
|
||||
self.assertGreater(game.percent, 0)
|
||||
self.assertLessEqual(game.percent, 100)
|
||||
@@ -302,47 +372,102 @@ class GameTests(unittest.TestCase):
|
||||
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:
|
||||
if game.state is State.FINISHED:
|
||||
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.assertIs(State.FINISHED, game.state)
|
||||
self.assertEqual(len(game.trailer.stack), 3)
|
||||
|
||||
def test_back_quits_and_primary_confirms_game_over(self) -> None:
|
||||
def test_back_quits(self) -> None:
|
||||
game = self.make_game()
|
||||
self.assertIs(game.handle(Action.BACK), HandleResult.QUIT)
|
||||
|
||||
def test_primary_after_finish_starts_new_round(self) -> None:
|
||||
game = self.make_game(sequence_length=2)
|
||||
self.assertTrue(game.handle(Action.PRIMARY))
|
||||
self.assertFalse(game.handle(Action.BACK))
|
||||
for _ in range(20000):
|
||||
if game.state is State.FINISHED:
|
||||
break
|
||||
if game.current is not None and game.current.allow_snap:
|
||||
game.handle(Action.PRIMARY)
|
||||
game.update(1 / 60)
|
||||
self.assertIs(game.state, State.FINISHED)
|
||||
self.assertIs(game.handle(Action.PRIMARY), HandleResult.NEW_ROUND)
|
||||
|
||||
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])
|
||||
def test_result_lines_show_difficulty_and_percent(self) -> None:
|
||||
game = self.make_game(difficulty=DIFFICULTIES[2])
|
||||
game.percent = 82
|
||||
lines = game.result_lines()
|
||||
self.assertIn("Hard", lines[0])
|
||||
self.assertIn("82%", lines[0])
|
||||
self.assertTrue(any("ESC" in line for line in lines))
|
||||
|
||||
|
||||
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"]
|
||||
class RenderTests(unittest.TestCase):
|
||||
"""Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).
|
||||
|
||||
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"]
|
||||
Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine
|
||||
Logiktests nicht bemerken.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
import pygame
|
||||
|
||||
from loader_game.assets import Assets
|
||||
|
||||
pygame.mixer.pre_init(44100, -16, 2, 512)
|
||||
pygame.init()
|
||||
cls.surface = pygame.display.set_mode(
|
||||
(config.SCREEN_WIDTH, config.SCREEN_HEIGHT)
|
||||
)
|
||||
cls.assets = Assets()
|
||||
cls.assets.load()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
import pygame
|
||||
|
||||
pygame.quit()
|
||||
|
||||
def make_game(self) -> Game:
|
||||
return Game(
|
||||
self.assets,
|
||||
[[CaseSpec(3, 1), CaseSpec(1, 2), CaseSpec(2, 2)]],
|
||||
difficulty=DIFFICULTIES[1],
|
||||
rng=random.Random(3),
|
||||
)
|
||||
|
||||
def test_intro_renders(self) -> None:
|
||||
from loader_game.intro import Intro
|
||||
|
||||
intro = Intro(self.assets, show_instruction=True)
|
||||
intro.draw(self.surface)
|
||||
intro.handle(Action.PRIMARY)
|
||||
for _ in range(200):
|
||||
intro.update(1 / 60)
|
||||
intro.draw(self.surface)
|
||||
|
||||
def test_menu_renders(self) -> None:
|
||||
menu = DifficultyMenu(self.assets, selected=DIFFICULTIES[0])
|
||||
menu.handle(Action.RIGHT)
|
||||
menu.draw(self.surface)
|
||||
|
||||
def test_game_and_result_render(self) -> None:
|
||||
game = self.make_game()
|
||||
game.draw(self.surface)
|
||||
game.percent = 61.5
|
||||
game.state = State.FINISHED
|
||||
game.draw(self.surface)
|
||||
|
||||
def test_rotated_case_draws_something(self) -> None:
|
||||
from loader_game import scene
|
||||
|
||||
case = Case(units_w=1, units_h=2, x=200.0, y=400.0)
|
||||
case.rotate_footprint()
|
||||
self.assertTrue(case.is_rotated)
|
||||
scene.draw_case(self.surface, self.assets, case)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user