diff --git a/src/doggo/__main__.py b/src/doggo/__main__.py index 01d3757..2540bf8 100644 --- a/src/doggo/__main__.py +++ b/src/doggo/__main__.py @@ -1,9 +1,5 @@ from __future__ import annotations -import os - -import pygame as pg - from doggo import ASSETS_PATH from doggo import config from doggo.config import COMPILED_ENV @@ -22,26 +18,18 @@ def run() -> None: Initialize the pygame and start the world. """ - # Check if the app should run in fullscreen mode. - fullscreen = os.getenv("DOGGO_FULLSCREEN", "False").lower() in ("1", "true") - - pg.init() - world = World( title=config.WORLD_TITLE, size=(config.WORLD_WIDTH, config.WORLD_HEIGHT), icon=ASSETS_PATH.joinpath("icon.png"), fps=config.WORLD_FPS, - fullscreen=fullscreen, + fullscreen=config.WORLD_FULLSCREEN, ) if COMPILED_ENV and WIN: pyi_splash.close() - try: - world.start() - except KeyboardInterrupt: - world.stop() + world.start() if __name__ == "__main__": diff --git a/src/doggo/config.py b/src/doggo/config.py index f2a9054..9f54335 100644 --- a/src/doggo/config.py +++ b/src/doggo/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from typing import TYPE_CHECKING @@ -26,6 +27,7 @@ WORLD_GROUND = ( WORLD_HEIGHT - WORLD_GROUND_HEIGHT ) # The ground level in the world, where the dog can walk on. +WORLD_FULLSCREEN = os.getenv("DOGGO_FULLSCREEN", "False").lower() in ("1", "true") # --- Dog sprite sheet configuration --- diff --git a/src/doggo/world.py b/src/doggo/world.py index 5e43e4f..0c4ab56 100644 --- a/src/doggo/world.py +++ b/src/doggo/world.py @@ -33,6 +33,7 @@ def __init__( fps: int = 60, fullscreen: bool = False, ) -> None: + pg.init() self.window: pg.window.Window = pg.window.Window( title=title, size=size, @@ -40,6 +41,7 @@ def __init__( always_on_top=True, fullscreen=fullscreen, ) + self._running: bool = False self.window_surf: pg.Surface = self.window.get_surface() self.screen: pg.Surface = pg.Surface(size=size) self.window.set_icon(pg.image.load(icon).convert_alpha()) @@ -47,12 +49,15 @@ def __init__( self.fullscreen: bool = fullscreen self.fps: int = fps self.clock: pg.time.Clock = pg.time.Clock() - self.running: bool = False self.dt: float = 0.0 self.prev_time: float = time.time() self.landscape = build_landscape() self.dog: Dog = build_dog() + def is_running(self) -> bool: + """Check if the world is running.""" + return self._running + def get_screen(self) -> pg.Surface: """Adapt the screen to the window size if fullscreen. @@ -70,7 +75,7 @@ def process_inputs(self) -> None: if event.type == pg.QUIT or ( event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE ): - self.running = False + self.stop() if not self.fullscreen: self.draggable.process_event(event=event) @@ -102,20 +107,32 @@ def start(self) -> None: f"for {self.dog.brain.current_state.countdown}s." ) - self.running = True - - while self.running: - self.get_dt() - self.process_inputs() - self.update() - self.render() - self.clock.tick(self.fps) - - self.stop() + self._running = True + self.run() + + def run(self) -> None: + """World game loop.""" + try: + while self.is_running(): + self.get_dt() + self.process_inputs() + self.update() + self.render() + self.clock.tick(self.fps) + except KeyboardInterrupt: + logger.info("A mysterious force stopped the world.") + except Exception as error: + logger.error(f"World crashed: {error}.") + finally: + self.destroy() + + def stop(self) -> None: + """Stop the world.""" + self._running = False + logger.info("World stopped. Dog is going to sleep.") @staticmethod - def stop() -> None: - """Stop the world.""" + def destroy() -> None: + """Destroy the world.""" pg.quit() - logger.info("World stopped. Dog is going to sleep.") sys.exit() diff --git a/tests/test_world.py b/tests/test_world.py index 8d3418f..1a6e1b4 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -2,20 +2,37 @@ import time +import pygame as pg import pytest from doggo import ASSETS_PATH from doggo.world import World -def test_world_initializes_correctly(): - world = World( - title="Doggo Test", - size=(340, 106), - icon=ASSETS_PATH.joinpath("icon.png"), - fps=30, - fullscreen=False, - ) +@pytest.fixture +def create_world(): + world = None + + def _(fullscreen=False): + nonlocal world + world = World( + title="Doggo Test", + size=(340, 106), + icon=ASSETS_PATH.joinpath("icon.png"), + fps=30, + fullscreen=fullscreen, + ) + + return world + + yield _ + + if world is not None: + world.window.destroy() + + +def test_world_initializes_correctly(create_world): + world = create_world() assert world.window.title == "Doggo Test" assert world.window.size == (340, 106) @@ -25,20 +42,13 @@ def test_world_initializes_correctly(): assert world.screen.get_size() == (340, 106) assert world.fullscreen is False assert world.fps == 30 - assert world.running is False + assert world._running is False assert world.dt == 0.0 @pytest.mark.parametrize("fullscreen", [True, False]) -def test_world_screen_is_scaled_regarding_fullscreen(fullscreen): - world = World( - title="Doggo Test", - size=(340, 106), - icon=ASSETS_PATH.joinpath("icon.png"), - fps=30, - fullscreen=fullscreen, - ) - +def test_world_screen_is_scaled_regarding_fullscreen(create_world, fullscreen): + world = create_world(fullscreen) screen = world.get_screen() if fullscreen: @@ -47,16 +57,140 @@ def test_world_screen_is_scaled_regarding_fullscreen(fullscreen): assert screen.get_size() == (340, 106) -def test_world_get_dt(): - world = World( - title="Doggo Test", - size=(340, 106), - icon=ASSETS_PATH.joinpath("icon.png"), - fps=30, - fullscreen=False, - ) +def test_world_get_dt(create_world): + world = create_world() time.sleep(0.1) world.get_dt() assert world.dt > 0.0 + + +@pytest.mark.parametrize( + "event", + [ + pg.event.Event(pg.QUIT), + pg.event.Event(pg.KEYDOWN, {"key": pg.K_ESCAPE}), + ], +) +def test_world_stop_at_some_events(mocker, create_world, event): + mocker.patch("pygame.event.get", return_value=[event]) + world = create_world() + world._running = True + + world.process_inputs() + + assert world._running is False + + +@pytest.mark.parametrize( + "fullscreen", + [True, False], +) +def test_world_draggable_window_is_processed_correctly( + mocker, create_world, fullscreen +): + mocker.patch("pygame.event.get", return_value=[pg.event.Event(pg.MOUSEBUTTONDOWN)]) + world = create_world(fullscreen) + mocker.patch.object(world.draggable, "process_event", spec=True) + + world.process_inputs() + + if fullscreen: + world.draggable.process_event.assert_not_called() + else: + world.draggable.process_event.assert_called_once_with(event=pg.event.get()[0]) + + +def test_world_update(mocker, create_world): + world = create_world() + world.dt = 0.1 + mocker.patch.object(world.dog, "update", spec=True) + + world.update() + + world.dog.update.assert_called_once_with(dt=0.1) + + +def test_world_render(mocker, create_world): + world = create_world() + screen = mocker.patch.object(world, "screen", spec=True) + landscape = mocker.patch.object(world, "landscape", spec=True) + dog = mocker.patch.object(world, "dog", spec=True) + window_surf = mocker.patch.object(world, "window_surf", spec=True) + window = mocker.patch.object(world, "window", spec=True) + + world.render() + + screen.fill.assert_called_once_with((135, 206, 235)) + landscape.background.draw.assert_called_once_with(screen=screen) + dog.draw.assert_called_once_with(screen=screen) + landscape.foreground.draw.assert_called_once_with(screen=screen) + window_surf.blit.assert_called_once_with(screen, (0, 0)) + window.flip.assert_called_once() + + +def test_world_start(mocker, create_world): + world = create_world() + run = mocker.patch.object(world, "run", spec=True) + + world.start() + + assert world._running is True + run.assert_called_once() + + +def test_world_run_game_loop(mocker, create_world): + world = create_world() + mocker.patch.object(world, "is_running", side_effect=[True, False]) + get_dt = mocker.patch.object(world, "get_dt", spec=True) + process_inputs = mocker.patch.object(world, "process_inputs", spec=True) + update = mocker.patch.object(world, "update", spec=True) + render = mocker.patch.object(world, "render", spec=True) + clock = mocker.patch.object(world, "clock", spec=True) + destroy = mocker.patch.object(world, "destroy", spec=True) + + world.run() + + get_dt.assert_called_once() + process_inputs.assert_called_once() + update.assert_called_once() + render.assert_called_once() + clock.tick.assert_called_once_with(world.fps) + destroy.assert_called_once() + + +@pytest.mark.parametrize("raise_exception", [KeyboardInterrupt, Exception]) +def test_world_run_initializes_dedstruction_on_exception( + mocker, create_world, raise_exception +): + world = create_world() + mocker.patch.object(world, "get_dt", spec=True, side_effect=[raise_exception]) + process_inputs = mocker.patch.object(world, "process_inputs", spec=True) + destroy = mocker.patch.object(world, "destroy", spec=True) + + world.start() # It will call the run method. + + process_inputs.assert_not_called() + destroy.assert_called_once() + + +def test_world_stop(mocker, create_world): + world = create_world() + mocker.patch.object(world, "run", spec=True) + world.start() + + world.stop() + + assert not world.is_running() + + +def test_world_destroy(mocker, create_world): + pg_quit = mocker.patch("pygame.quit") + sys_exit = mocker.patch("sys.exit") + world = create_world() + + world.destroy() + + pg_quit.assert_called_once() + sys_exit.assert_called_once()