diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 98a642a..f0ad13f 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -84,6 +84,31 @@ jobs: - name: Setup Python run: uv python install + - name: Install dependencies + run: uv sync + + - name: Copy libaudio to project root (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cp libs/audio/*.dll . 2>/dev/null || echo "libaudio not found" + + - name: Copy libaudio to project root (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + cp libs/audio/libaudio.dylib . 2>/dev/null || echo "libaudio not found" + + - name: Copy libaudio to project root (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cp libs/audio/libaudio.so . 2>/dev/null || echo "libaudio not found" + + - name: Run tests + run: uv run pytest test/libs/ -v --tb=short + continue-on-error: false + - name: Build Executable shell: bash run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ffc704a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,146 @@ +name: Tests + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master, develop ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest, macos-latest] + python-version: ['3.12'] + + runs-on: ${{ matrix.os }} + + steps: + - name: Check-out repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install libaudio Dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew update + brew install portaudio libsndfile speexdsp ccache + + - name: Install libaudio Dependencies (Windows) + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + update: true + install: >- + base-devel + mingw-w64-x86_64-gcc + mingw-w64-x86_64-libsndfile + mingw-w64-x86_64-speexdsp + mingw-w64-x86_64-ccache + + - name: Install libaudio Dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libsndfile1-dev \ + libspeexdsp-dev \ + portaudio19-dev \ + libpulse-dev \ + ccache + + - name: Build libaudio (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: | + cd libs/audio + make clean + make all + make verify + + - name: Build libaudio (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + cd libs/audio + make clean + make all + make verify + + - name: Copy libaudio to project root (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cp libs/audio/*.dll . 2>/dev/null || echo "libaudio not found" + + - name: Copy libaudio to project root (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + cp libs/audio/libaudio.dylib . 2>/dev/null || echo "libaudio not found" + + - name: Copy libaudio to project root (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cp libs/audio/libaudio.so . 2>/dev/null || echo "libaudio not found" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Setup Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest test/libs/ -v --tb=short --color=yes + continue-on-error: false + + - name: Run tests with coverage + if: matrix.os == 'ubuntu-22.04' + run: | + uv run pytest test/libs/ --cov=libs --cov-report=xml --cov-report=html --cov-report=term + + - name: Upload coverage reports + if: matrix.os == 'ubuntu-22.04' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + continue-on-error: true + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }}-py${{ matrix.python-version }} + path: | + *.log + temp/ + if-no-files-found: ignore + retention-days: 7 + + test-summary: + runs-on: ubuntu-latest + needs: test + if: always() + steps: + - name: Test Summary + run: | + echo "## Test Results Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "All platform tests completed!" >> $GITHUB_STEP_SUMMARY diff --git a/libs/audio.py b/libs/audio.py index 73e7e8f..ced6ef7 100644 --- a/libs/audio.py +++ b/libs/audio.py @@ -119,7 +119,8 @@ except OSError as e: class AudioEngine: """Initialize an audio engine for playing sounds and music.""" - def __init__(self, device_type: int, sample_rate: float, buffer_size: int, volume_presets: VolumeConfig): + def __init__(self, device_type: int, sample_rate: float, buffer_size: int, + volume_presets: VolumeConfig, sounds_path: Path | None = None): self.device_type = max(device_type, 0) if sample_rate < 0: self.target_sample_rate = 44100 @@ -131,7 +132,10 @@ class AudioEngine: self.audio_device_ready = False self.volume_presets = volume_presets - self.sounds_path = Path(f"Skins/{get_config()["paths"]["skin"]}/Sounds") + if sounds_path is None: + self.sounds_path = Path(f"Skins/{get_config()['paths']['skin']}/Sounds") + else: + self.sounds_path = sounds_path def set_log_level(self, level: int): lib.set_log_level(level) # type: ignore diff --git a/pyproject.toml b/pyproject.toml index ffa79bf..0fb983b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,48 @@ requires-python = ">=3.13" dependencies = [ "av>=16.0.1", "pypresence>=4.6.1", + "pytest>=9.0.2", "raylib-sdl>=5.5.0.2", "tomlkit>=0.13.3", ] +[tool.pytest.ini_options] +testpaths = ["test"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = [ + "-v", + "--strict-markers", + "--tb=short", + "--color=yes", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] + +[tool.coverage.run] +source = ["libs"] +omit = [ + "*/test/*", + "*/__pycache__/*", + "*/venv/*", + "*/.venv/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + [tool.vulture] exclude = ["*.git", ".github/", ".venv/", "cache/"] paths = ["."] @@ -18,4 +56,5 @@ paths = ["."] [dependency-groups] dev = [ "nuitka>=2.8.4", + "pytest-cov>=6.0.0", ] diff --git a/test/libs/test_animation.py b/test/libs/test_animation.py new file mode 100644 index 0000000..ed259c2 --- /dev/null +++ b/test/libs/test_animation.py @@ -0,0 +1,695 @@ +import unittest +from unittest.mock import patch + +from libs.animation import ( + Animation, + BaseAnimation, + FadeAnimation, + MoveAnimation, + TextStretchAnimation, + TextureChangeAnimation, + TextureResizeAnimation, + parse_animations, +) + + +class TestBaseAnimation(unittest.TestCase): + """Test cases for the BaseAnimation class.""" + + @patch('libs.animation.get_current_ms') + @patch('libs.animation.global_data') + def setUp(self, mock_global_data, mock_get_ms): + """Set up test fixtures.""" + mock_get_ms.return_value = 0.0 + mock_global_data.input_locked = 0 + + @patch('libs.animation.get_current_ms') + def test_initialization(self, mock_get_ms): + """Test basic initialization of BaseAnimation.""" + mock_get_ms.return_value = 100.0 + + anim = BaseAnimation(duration=1000.0, delay=100.0, loop=True, lock_input=True) + + self.assertEqual(anim.duration, 1000.0) + self.assertEqual(anim.delay, 100.0) + self.assertEqual(anim.delay_saved, 100.0) + self.assertEqual(anim.start_ms, 100.0) + self.assertFalse(anim.is_finished) + self.assertEqual(anim.attribute, 0) + self.assertFalse(anim.is_started) + self.assertTrue(anim.loop) + self.assertTrue(anim.lock_input) + + @patch('libs.animation.get_current_ms') + @patch('libs.animation.global_data') + def test_start(self, mock_global_data, mock_get_ms): + """Test starting an animation.""" + mock_get_ms.return_value = 200.0 + mock_global_data.input_locked = 0 + + anim = BaseAnimation(duration=1000.0, lock_input=True) + anim.start() + + self.assertTrue(anim.is_started) + self.assertFalse(anim.is_finished) + self.assertEqual(mock_global_data.input_locked, 1) + + @patch('libs.animation.get_current_ms') + @patch('libs.animation.global_data') + def test_restart(self, mock_global_data, mock_get_ms): + """Test restarting an animation.""" + mock_get_ms.side_effect = [0.0, 500.0, 1000.0] + mock_global_data.input_locked = 0 + + anim = BaseAnimation(duration=1000.0, delay=100.0, lock_input=True) + anim.is_finished = True + anim.delay = 0.0 + + anim.restart() + + self.assertEqual(anim.start_ms, 500.0) + self.assertFalse(anim.is_finished) + self.assertEqual(anim.delay, 100.0) + self.assertEqual(mock_global_data.input_locked, 1) + + @patch('libs.animation.get_current_ms') + @patch('libs.animation.global_data') + def test_pause_unpause(self, mock_global_data, mock_get_ms): + """Test pausing and unpausing.""" + mock_get_ms.return_value = 0.0 + mock_global_data.input_locked = 1 + + anim = BaseAnimation(duration=1000.0, lock_input=True) + anim.is_started = True + + anim.pause() + self.assertFalse(anim.is_started) + self.assertEqual(mock_global_data.input_locked, 0) + + anim.unpause() + self.assertTrue(anim.is_started) + self.assertEqual(mock_global_data.input_locked, 1) + + @patch('libs.animation.get_current_ms') + def test_loop_restarts(self, mock_get_ms): + """Test that looped animations restart when finished.""" + mock_get_ms.side_effect = [0.0, 100.0] + + anim = BaseAnimation(duration=1000.0, loop=True) + anim.is_finished = True + + with patch.object(anim, 'restart') as mock_restart: + anim.update(100.0) + mock_restart.assert_called_once() + + @patch('libs.animation.get_current_ms') + @patch('libs.animation.global_data') + def test_input_lock_unlock(self, mock_global_data, mock_get_ms): + """Test input locking mechanism.""" + mock_get_ms.return_value = 0.0 + mock_global_data.input_locked = 1 + + anim = BaseAnimation(duration=1000.0, lock_input=True) + anim.is_finished = True + anim.unlocked = False + + anim.update(100.0) + + self.assertTrue(anim.unlocked) + self.assertEqual(mock_global_data.input_locked, 0) + + def test_easing_functions(self): + """Test easing functions produce expected values.""" + anim = BaseAnimation(duration=1000.0) + + # Test quadratic ease in + self.assertAlmostEqual(anim._ease_in(0.5, "quadratic"), 0.25) + self.assertAlmostEqual(anim._ease_in(1.0, "quadratic"), 1.0) + + # Test cubic ease in + self.assertAlmostEqual(anim._ease_in(0.5, "cubic"), 0.125) + + # Test exponential ease in + self.assertEqual(anim._ease_in(0.0, "exponential"), 0) + + # Test quadratic ease out + self.assertAlmostEqual(anim._ease_out(0.5, "quadratic"), 0.75) + + # Test cubic ease out + self.assertAlmostEqual(anim._ease_out(0.5, "cubic"), 0.875) + + # Test exponential ease out + self.assertEqual(anim._ease_out(1.0, "exponential"), 1) + + +class TestFadeAnimation(unittest.TestCase): + """Test cases for the FadeAnimation class.""" + + @patch('libs.animation.get_current_ms') + def test_initialization(self, mock_get_ms): + """Test fade animation initialization.""" + mock_get_ms.return_value = 0.0 + + anim = FadeAnimation( + duration=1000.0, + initial_opacity=1.0, + final_opacity=0.0, + delay=100.0, + ease_in="quadratic" + ) + + self.assertEqual(anim.initial_opacity, 1.0) + self.assertEqual(anim.final_opacity, 0.0) + self.assertEqual(anim.attribute, 1.0) + self.assertEqual(anim.ease_in, "quadratic") + + @patch('libs.animation.get_current_ms') + def test_fade_during_delay(self, mock_get_ms): + """Test that opacity stays at initial during delay.""" + mock_get_ms.return_value = 0.0 + + anim = FadeAnimation(duration=1000.0, initial_opacity=1.0, final_opacity=0.0, delay=500.0) + anim.start() + + anim.update(250.0) # Within delay period + + self.assertEqual(anim.attribute, 1.0) + self.assertFalse(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_fade_progression(self, mock_get_ms): + """Test fade progresses correctly.""" + mock_get_ms.return_value = 0.0 + + anim = FadeAnimation(duration=1000.0, initial_opacity=1.0, final_opacity=0.0) + anim.start() + + anim.update(500.0) # Halfway through + + self.assertAlmostEqual(anim.attribute, 0.5, places=2) + self.assertFalse(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_fade_completion(self, mock_get_ms): + """Test fade completes at final opacity.""" + mock_get_ms.return_value = 0.0 + + anim = FadeAnimation(duration=1000.0, initial_opacity=1.0, final_opacity=0.0) + anim.start() + + anim.update(1000.0) # End of animation + + self.assertEqual(anim.attribute, 0.0) + self.assertTrue(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_fade_with_reverse_delay(self, mock_get_ms): + """Test fade reverses after reverse_delay.""" + mock_get_ms.return_value = 0.0 + + anim = FadeAnimation( + duration=1000.0, + initial_opacity=1.0, + final_opacity=0.0, + reverse_delay=200.0 + ) + anim.start() + + anim.update(1000.0) # Complete first fade + + self.assertEqual(anim.attribute, 0.0) + self.assertTrue(anim.is_reversing) + self.assertEqual(anim.initial_opacity, 0.0) + self.assertEqual(anim.final_opacity, 1.0) + self.assertFalse(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_fade_with_easing(self, mock_get_ms): + """Test fade applies easing correctly.""" + mock_get_ms.return_value = 0.0 + + anim = FadeAnimation( + duration=1000.0, + initial_opacity=0.0, + final_opacity=1.0, + ease_in="quadratic" + ) + anim.start() + + anim.update(500.0) # Halfway + + # With quadratic ease in, at 0.5 progress we should have 0.25 + self.assertAlmostEqual(anim.attribute, 0.25, places=2) + + +class TestMoveAnimation(unittest.TestCase): + """Test cases for the MoveAnimation class.""" + + @patch('libs.animation.get_current_ms') + def test_initialization(self, mock_get_ms): + """Test move animation initialization.""" + mock_get_ms.return_value = 0.0 + + anim = MoveAnimation( + duration=1000.0, + start_position=0, + total_distance=100, + delay=50.0 + ) + + self.assertEqual(anim.start_position, 0) + self.assertEqual(anim.total_distance, 100) + self.assertEqual(anim.delay, 50.0) + + @patch('libs.animation.get_current_ms') + def test_move_during_delay(self, mock_get_ms): + """Test position stays at start during delay.""" + mock_get_ms.return_value = 0.0 + + anim = MoveAnimation(duration=1000.0, start_position=50, total_distance=100, delay=200.0) + anim.start() + + anim.update(100.0) # Within delay + + self.assertEqual(anim.attribute, 50) + + @patch('libs.animation.get_current_ms') + def test_move_progression(self, mock_get_ms): + """Test move progresses correctly.""" + mock_get_ms.return_value = 0.0 + + anim = MoveAnimation(duration=1000.0, start_position=0, total_distance=100) + anim.start() + + anim.update(500.0) # Halfway + + self.assertAlmostEqual(anim.attribute, 50.0, places=2) + + @patch('libs.animation.get_current_ms') + def test_move_completion(self, mock_get_ms): + """Test move completes at final position.""" + mock_get_ms.return_value = 0.0 + + anim = MoveAnimation(duration=1000.0, start_position=0, total_distance=100) + anim.start() + + anim.update(1000.0) + + self.assertEqual(anim.attribute, 100) + self.assertTrue(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_move_with_reverse_delay(self, mock_get_ms): + """Test move reverses after reverse_delay.""" + mock_get_ms.return_value = 0.0 + + anim = MoveAnimation( + duration=1000.0, + start_position=0, + total_distance=100, + reverse_delay=100.0 + ) + anim.start() + + anim.update(1000.0) # Complete first move + + self.assertEqual(anim.start_position, 100) + self.assertEqual(anim.total_distance, -100) + self.assertIsNone(anim.reverse_delay) + self.assertFalse(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_move_with_easing(self, mock_get_ms): + """Test move applies easing.""" + mock_get_ms.return_value = 0.0 + + anim = MoveAnimation( + duration=1000.0, + start_position=0, + total_distance=100, + ease_out="quadratic" + ) + anim.start() + + anim.update(500.0) + + # With quadratic ease out, at 0.5 progress we should have 0.75 + self.assertAlmostEqual(anim.attribute, 75.0, places=2) + + +class TestTextureChangeAnimation(unittest.TestCase): + """Test cases for the TextureChangeAnimation class.""" + + @patch('libs.animation.get_current_ms') + def test_initialization(self, mock_get_ms): + """Test texture change animation initialization.""" + mock_get_ms.return_value = 0.0 + + textures = [(0.0, 100.0, 0), (100.0, 200.0, 1), (200.0, 300.0, 2)] + anim = TextureChangeAnimation(duration=300.0, textures=textures) + + self.assertEqual(anim.textures, textures) + self.assertEqual(anim.attribute, 0) # First texture index + + @patch('libs.animation.get_current_ms') + def test_texture_change_progression(self, mock_get_ms): + """Test texture changes at correct times.""" + mock_get_ms.return_value = 0.0 + + textures = [(0.0, 100.0, 0), (100.0, 200.0, 1), (200.0, 300.0, 2)] + anim = TextureChangeAnimation(duration=300.0, textures=textures) + anim.start() + + anim.update(50.0) + self.assertEqual(anim.attribute, 0) + + anim.update(150.0) + self.assertEqual(anim.attribute, 1) + + anim.update(250.0) + self.assertEqual(anim.attribute, 2) + + @patch('libs.animation.get_current_ms') + def test_texture_change_completion(self, mock_get_ms): + """Test texture change completes.""" + mock_get_ms.return_value = 0.0 + + textures = [(0.0, 100.0, 0), (100.0, 200.0, 1)] + anim = TextureChangeAnimation(duration=200.0, textures=textures) + anim.start() + + anim.update(300.0) # Past duration + + self.assertTrue(anim.is_finished) + + @patch('libs.animation.get_current_ms') + def test_texture_change_with_delay(self, mock_get_ms): + """Test texture change respects delay.""" + mock_get_ms.return_value = 0.0 + + textures = [(0.0, 100.0, 0), (100.0, 200.0, 1)] + anim = TextureChangeAnimation(duration=200.0, textures=textures, delay=100.0) + anim.start() + + anim.update(50.0) # During delay + self.assertEqual(anim.attribute, 0) + + anim.update(150.0) # 50ms into animation (after delay) + self.assertEqual(anim.attribute, 0) + + +class TestTextureResizeAnimation(unittest.TestCase): + """Test cases for the TextureResizeAnimation class.""" + + @patch('libs.animation.get_current_ms') + def test_initialization(self, mock_get_ms): + """Test texture resize initialization.""" + mock_get_ms.return_value = 0.0 + + anim = TextureResizeAnimation( + duration=1000.0, + initial_size=1.0, + final_size=2.0 + ) + + self.assertEqual(anim.initial_size, 1.0) + self.assertEqual(anim.final_size, 2.0) + self.assertEqual(anim.attribute, 1.0) + + @patch('libs.animation.get_current_ms') + def test_resize_progression(self, mock_get_ms): + """Test resize progresses correctly.""" + mock_get_ms.return_value = 0.0 + + anim = TextureResizeAnimation(duration=1000.0, initial_size=1.0, final_size=2.0) + anim.start() + + anim.update(500.0) # Halfway + + self.assertAlmostEqual(anim.attribute, 1.5, places=2) + + @patch('libs.animation.get_current_ms') + def test_resize_completion(self, mock_get_ms): + """Test resize completes.""" + mock_get_ms.return_value = 0.0 + + anim = TextureResizeAnimation(duration=1000.0, initial_size=1.0, final_size=0.5) + anim.start() + + anim.update(1000.0) + + self.assertEqual(anim.attribute, 0.5) + self.assertTrue(anim.is_finished) + + +class TestTextStretchAnimation(unittest.TestCase): + """Test cases for the TextStretchAnimation class.""" + + @patch('libs.animation.get_current_ms') + def test_stretch_phases(self, mock_get_ms): + """Test text stretch animation phases.""" + mock_get_ms.return_value = 0.0 + + anim = TextStretchAnimation(duration=100.0) + anim.start() + + # Phase 1: Growing + anim.update(50.0) + self.assertGreater(anim.attribute, 2) + + # Phase 2: Shrinking back + anim.update(150.0) + self.assertGreater(anim.attribute, 0) + + # Phase 3: Finished + anim.update(300.0) + self.assertEqual(anim.attribute, 0) + self.assertTrue(anim.is_finished) + + +class TestAnimationFactory(unittest.TestCase): + """Test cases for the Animation factory class.""" + + def test_create_fade(self): + """Test factory creates fade animation.""" + anim = Animation.create_fade(1000.0, initial_opacity=1.0, final_opacity=0.0) + + self.assertIsInstance(anim, FadeAnimation) + self.assertEqual(anim.duration, 1000.0) + self.assertEqual(anim.initial_opacity, 1.0) + + def test_create_move(self): + """Test factory creates move animation.""" + anim = Animation.create_move(1000.0, start_position=0, total_distance=100) + + self.assertIsInstance(anim, MoveAnimation) + self.assertEqual(anim.duration, 1000.0) + self.assertEqual(anim.total_distance, 100) + + def test_create_texture_change(self): + """Test factory creates texture change animation.""" + textures = [(0.0, 100.0, 0)] + anim = Animation.create_texture_change(1000.0, textures=textures) + + self.assertIsInstance(anim, TextureChangeAnimation) + self.assertEqual(anim.textures, textures) + + def test_create_texture_resize(self): + """Test factory creates texture resize animation.""" + anim = Animation.create_texture_resize(1000.0, initial_size=1.0, final_size=2.0) + + self.assertIsInstance(anim, TextureResizeAnimation) + self.assertEqual(anim.initial_size, 1.0) + + +class TestParseAnimations(unittest.TestCase): + """Test cases for parse_animations function.""" + + def test_parse_basic_animation(self): + """Test parsing a simple animation.""" + animation_json = [ + { + "id": 1, + "type": "fade", + "duration": 1000.0, + "initial_opacity": 1.0, + "final_opacity": 0.0 + } + ] + + result = parse_animations(animation_json) + + self.assertIn(1, result) + self.assertIsInstance(result[1], FadeAnimation) + self.assertEqual(result[1].duration, 1000.0) + + def test_parse_multiple_animations(self): + """Test parsing multiple animations.""" + animation_json = [ + {"id": 1, "type": "fade", "duration": 1000.0}, + {"id": 2, "type": "move", "duration": 500.0, "total_distance": 50} + ] + + result = parse_animations(animation_json) + + self.assertEqual(len(result), 2) + self.assertIsInstance(result[1], FadeAnimation) + self.assertIsInstance(result[2], MoveAnimation) + + def test_parse_with_reference(self): + """Test parsing animations with references.""" + animation_json = [ + {"id": 1, "type": "fade", "duration": 1000.0, "initial_opacity": 1.0}, + { + "id": 2, + "type": "fade", + "duration": {"reference_id": 1, "property": "duration"}, + "initial_opacity": 0.5 + } + ] + + result = parse_animations(animation_json) + + self.assertEqual(result[2].duration, 1000.0) + self.assertEqual(result[2].initial_opacity, 0.5) + + def test_parse_with_reference_and_init_val(self): + """Test parsing with reference and init_val modifier.""" + animation_json = [ + {"id": 1, "type": "fade", "duration": 1000.0}, + { + "id": 2, + "type": "fade", + "duration": { + "reference_id": 1, + "property": "duration", + "init_val": 500.0 + } + } + ] + + result = parse_animations(animation_json) + + self.assertEqual(result[2].duration, 1500.0) + + def test_parse_missing_id_raises_error(self): + """Test that missing id raises exception.""" + animation_json = [ + {"type": "fade", "duration": 1000.0} + ] + + with self.assertRaises(Exception) as context: + parse_animations(animation_json) + self.assertIn("requires id", str(context.exception)) + + def test_parse_missing_type_raises_error(self): + """Test that missing type raises exception.""" + animation_json = [ + {"id": 1, "duration": 1000.0} + ] + + with self.assertRaises(Exception) as context: + parse_animations(animation_json) + self.assertIn("requires type", str(context.exception)) + + def test_parse_circular_reference_raises_error(self): + """Test that circular references are detected.""" + animation_json = [ + { + "id": 1, + "type": "fade", + "duration": {"reference_id": 2, "property": "duration"} + }, + { + "id": 2, + "type": "fade", + "duration": {"reference_id": 1, "property": "duration"} + } + ] + + with self.assertRaises(Exception) as context: + parse_animations(animation_json) + self.assertIn("Circular reference", str(context.exception)) + + def test_parse_unknown_type_raises_error(self): + """Test that unknown animation type raises exception.""" + animation_json = [ + {"id": 1, "type": "unknown_type", "duration": 1000.0} + ] + + with self.assertRaises(Exception) as context: + parse_animations(animation_json) + self.assertIn("Unknown Animation type", str(context.exception)) + + def test_parse_missing_reference_property_raises_error(self): + """Test that missing reference property raises exception.""" + animation_json = [ + {"id": 1, "type": "fade", "duration": 1000.0}, + { + "id": 2, + "type": "fade", + "duration": {"reference_id": 1} + } + ] + + with self.assertRaises(Exception) as context: + parse_animations(animation_json) + self.assertIn("requires 'property'", str(context.exception)) + + def test_parse_nonexistent_reference_raises_error(self): + """Test that referencing nonexistent animation raises exception.""" + animation_json = [ + { + "id": 1, + "type": "fade", + "duration": {"reference_id": 999, "property": "duration"} + } + ] + + with self.assertRaises(Exception) as context: + parse_animations(animation_json) + self.assertIn("not found", str(context.exception)) + + def test_parse_ignores_comments(self): + """Test that comments are ignored during parsing.""" + animation_json = [ + { + "id": 1, + "type": "fade", + "duration": 1000.0, + "comment": "This is a fade animation" + } + ] + + result = parse_animations(animation_json) + + self.assertIn(1, result) + self.assertIsInstance(result[1], FadeAnimation) + + def test_parse_nested_references(self): + """Test parsing nested reference chains.""" + animation_json = [ + {"id": 1, "type": "fade", "duration": 1000.0}, + { + "id": 2, + "type": "fade", + "duration": {"reference_id": 1, "property": "duration"} + }, + { + "id": 3, + "type": "fade", + "duration": { + "reference_id": 2, + "property": "duration", + "init_val": 500.0 + } + } + ] + + result = parse_animations(animation_json) + + self.assertEqual(result[3].duration, 1500.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/libs/test_audio.py b/test/libs/test_audio.py new file mode 100644 index 0000000..f1e3599 --- /dev/null +++ b/test/libs/test_audio.py @@ -0,0 +1,518 @@ +import shutil +import struct +import unittest +import wave +from pathlib import Path +from unittest.mock import patch + +from libs.audio import AudioEngine, audio +from libs.config import VolumeConfig + +DEFAULT_CONFIG = VolumeConfig(sound=0.8, music=0.7, voice=0.6, hitsound=0.5, attract_mode=0.4) + +class TestAudioEngine(unittest.TestCase): + """Integration tests using the audio library.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures once for all tests.""" + # Create temporary directory for test audio files + cls.test_dir = Path().cwd() / Path("temp") + cls.test_dir.mkdir(exist_ok=True) + cls.sounds_dir = Path(cls.test_dir) / "Sounds" + cls.sounds_dir.mkdir(exist_ok=True) + + # Create test WAV files + cls._create_test_wav(cls.sounds_dir / "don.wav") + cls._create_test_wav(cls.sounds_dir / "ka.wav") + cls._create_test_wav(cls.sounds_dir / "test_sound.wav") + cls._create_test_wav(cls.sounds_dir / "test_music.wav", duration=2.0) + + # Create screen sounds directory + cls.screen_sounds = cls.sounds_dir / "menu" + cls.screen_sounds.mkdir() + cls._create_test_wav(cls.screen_sounds / "click.wav") + cls._create_test_wav(cls.screen_sounds / "hover.wav") + + # Create global sounds directory + cls.global_sounds = cls.sounds_dir / "global" + cls.global_sounds.mkdir() + cls._create_test_wav(cls.global_sounds / "confirm.wav") + + cls.volume_presets = DEFAULT_CONFIG + + @classmethod + def tearDownClass(cls): + """Clean up test files.""" + shutil.rmtree(cls.test_dir) + + @staticmethod + def _create_test_wav(filepath, duration=0.1, frequency=440): + """Create a simple test WAV file.""" + sample_rate = 44100 + num_samples = int(sample_rate * duration) + + with wave.open(str(filepath), 'w') as wav_file: + wav_file.setnchannels(1) # Mono + wav_file.setsampwidth(2) # 16-bit + wav_file.setframerate(sample_rate) + + for i in range(num_samples): + # Generate a simple sine wave + value = int(32767.0 * 0.3 * + (i % (sample_rate // frequency)) / + (sample_rate // frequency)) + wav_file.writeframes(struct.pack('h', value)) + + def setUp(self): + """Set up each test.""" + self.mock_config_path = self.sounds_dir + # Store original audio singleton state to avoid test pollution + self._original_audio_sounds_path = audio.sounds_path + + def tearDown(self): + """Tear down each test.""" + # Restore original audio singleton state + audio.sounds_path = self._original_audio_sounds_path + # Clear any sounds or music loaded during tests + if hasattr(audio, 'sounds') and isinstance(audio.sounds, dict): + audio.sounds.clear() + if hasattr(audio, 'music_streams') and isinstance(audio.music_streams, dict): + audio.music_streams.clear() + + @patch('libs.audio.get_config') + def test_initialization(self, mock_config): + """Test AudioEngine initialization.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + + engine = AudioEngine( + device_type=0, + sample_rate=44100.0, + buffer_size=512, + volume_presets=self.volume_presets, + sounds_path=self.sounds_dir + ) + + self.assertEqual(engine.device_type, 0) + self.assertEqual(engine.target_sample_rate, 44100.0) + self.assertEqual(engine.buffer_size, 512) + self.assertEqual(engine.volume_presets, self.volume_presets) + self.assertFalse(engine.audio_device_ready) + + @patch('libs.audio.get_config') + def test_init_and_close_audio_device(self, mock_config): + """Test initializing and closing audio device.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + # Initialize + success = engine.init_audio_device() + self.assertTrue(success) + self.assertTrue(engine.audio_device_ready) + self.assertTrue(engine.is_audio_device_ready()) + + # Close + engine.close_audio_device() + self.assertFalse(engine.audio_device_ready) + + @patch('libs.audio.get_config') + def test_master_volume(self, mock_config): + """Test master volume control.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Set and get master volume + engine.set_master_volume(0.75) + volume = engine.get_master_volume() + self.assertAlmostEqual(volume, 0.75, places=2) + + # Test clamping + engine.set_master_volume(1.5) + volume = engine.get_master_volume() + self.assertLessEqual(volume, 1.0) + + engine.set_master_volume(-0.5) + volume = engine.get_master_volume() + self.assertGreaterEqual(volume, 0.0) + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_load_and_unload_sound(self, mock_config): + """Test loading and unloading sounds.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Load sound + sound_path = self.sounds_dir / "test_sound.wav" + sound_id = engine.load_sound(sound_path, "test") + + self.assertEqual(sound_id, "test") + self.assertIn("test", engine.sounds) + + # Unload sound + engine.unload_sound("test") + self.assertNotIn("test", engine.sounds) + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_load_nonexistent_sound(self, mock_config): + """Test loading a non-existent sound file.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + sound_id = engine.load_sound(Path("nonexistent.wav"), "bad") + self.assertEqual(sound_id, "") + self.assertNotIn("bad", engine.sounds) + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_play_and_stop_sound(self, mock_config): + """Test playing and stopping sounds.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Load and play sound + sound_path = self.sounds_dir / "test_sound.wav" + engine.load_sound(sound_path, "test") + + engine.play_sound("test", "sound") + + # Give it a moment to start + import time + time.sleep(0.05) + + # Check if playing (might not be if audio is very short) + # Just verify no exceptions were raised + is_playing = engine.is_sound_playing("test") + self.assertIsInstance(is_playing, bool) + + # Stop sound + engine.stop_sound("test") + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_play_don_and_kat(self, mock_config): + """Test playing the special don and kat sounds.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Play don + engine.play_sound("don", "sound") + is_playing = engine.is_sound_playing("don") + self.assertIsInstance(is_playing, bool) + engine.stop_sound("don") + + # Play kat + engine.play_sound("kat", "sound") + is_playing = engine.is_sound_playing("kat") + self.assertIsInstance(is_playing, bool) + engine.stop_sound("kat") + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_sound_volume_control(self, mock_config): + """Test setting sound volume.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + sound_path = self.sounds_dir / "test_sound.wav" + engine.load_sound(sound_path, "test") + + # Set volume (should not raise exception) + engine.set_sound_volume("test", 0.5) + engine.play_sound("test", "") + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_sound_pan_control(self, mock_config): + """Test setting sound pan.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + sound_path = self.sounds_dir / "test_sound.wav" + engine.load_sound(sound_path, "test") + + # Set pan (should not raise exception) + engine.set_sound_pan("test", -0.5) # Left + engine.set_sound_pan("test", 0.5) # Right + engine.set_sound_pan("test", 0.0) # Center + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_load_screen_sounds(self, mock_config): + """Test loading sounds for a screen.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + engine.load_screen_sounds("menu") + + # Check that screen sounds were loaded + self.assertIn("click", engine.sounds) + self.assertIn("hover", engine.sounds) + + # Check that global sounds were loaded + self.assertIn("confirm", engine.sounds) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_unload_all_sounds(self, mock_config): + """Test unloading all sounds.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Load multiple sounds + engine.load_sound(self.sounds_dir / "test_sound.wav", "s1") + engine.load_sound(self.sounds_dir / "test_sound.wav", "s2") + engine.load_sound(self.sounds_dir / "test_sound.wav", "s3") + + self.assertEqual(len(engine.sounds), 3) + + # Unload all + engine.unload_all_sounds() + self.assertEqual(len(engine.sounds), 0) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_load_and_play_music_stream(self, mock_config): + """Test loading and playing music streams.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + music_path = self.sounds_dir / "test_music.wav" + music_id = engine.load_music_stream(music_path, "bgm") + print(music_id) + + self.assertEqual(music_id, "bgm") + self.assertIn("bgm", engine.music_streams) + + # Play music + engine.play_music_stream("bgm", "music") + + # Update music stream + engine.update_music_stream("bgm") + + # Check if playing + is_playing = engine.is_music_stream_playing("bgm") + self.assertIsInstance(is_playing, bool) + + # Stop music + engine.stop_music_stream("bgm") + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_music_time_functions(self, mock_config): + """Test getting music time length and played.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + music_path = self.sounds_dir / "test_music.wav" + music_file = engine.load_music_stream(music_path, "bgm") + + # Get time length + length = engine.get_music_time_length(music_file) + self.assertGreater(length, 0.0) + self.assertLess(length, 10.0) # Should be around 2 seconds + + # Play and get time played + engine.play_music_stream(music_file, "music") + engine.update_music_stream(music_file) + + import time + time.sleep(0.1) + engine.update_music_stream(music_file) + + played = engine.get_music_time_played(music_file) + self.assertGreaterEqual(played, 0.0) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_music_volume_control(self, mock_config): + """Test setting music volume.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + music_path = self.sounds_dir / "test_music.wav" + engine.load_music_stream(music_path, "bgm") + + # Set volume (should not raise exception) + engine.set_music_volume("bgm", 0.6) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_seek_music_stream(self, mock_config): + """Test seeking in music stream.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + music_path = self.sounds_dir / "test_music.wav" + engine.load_music_stream(music_path, "bgm") + + # Seek to position (should not raise exception) + engine.seek_music_stream("bgm", 0.5) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_unload_music_stream(self, mock_config): + """Test unloading music stream.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + + if engine.init_audio_device(): + try: + music_path = self.sounds_dir / "test_music.wav" + engine.load_music_stream(music_path, "bgm") + + self.assertIn("bgm", engine.music_streams) + + engine.unload_music_stream("bgm") + self.assertNotIn("bgm", engine.music_streams) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_unload_all_music(self, mock_config): + """Test unloading all music streams.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Load multiple music streams + music_path = self.sounds_dir / "test_music.wav" + engine.load_music_stream(music_path, "bgm1") + engine.load_music_stream(music_path, "bgm2") + + self.assertEqual(len(engine.music_streams), 2) + + engine.unload_all_music() + self.assertEqual(len(engine.music_streams), 0) + + finally: + engine.close_audio_device() + + @patch('libs.audio.get_config') + def test_host_api_functions(self, mock_config): + """Test host API query functions.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + engine.init_audio_device() + + # List host APIs (should not crash) + engine.list_host_apis() + + # Get host API name + name = engine.get_host_api_name(0) + self.assertIsInstance(name, str) + + @patch('libs.audio.get_config') + def test_full_lifecycle(self, mock_config): + """Test complete audio engine lifecycle.""" + mock_config.return_value = {"paths": {"skin": f"{self.sounds_dir}"}} + engine = AudioEngine(0, 44100.0, 512, self.volume_presets, sounds_path=self.sounds_dir) + engine.set_log_level(10) + + if engine.init_audio_device(): + try: + # Load sounds and music + engine.load_sound(self.sounds_dir / "test_sound.wav", "sfx") + engine.load_music_stream(self.sounds_dir / "test_music.wav", "bgm") + + # Set volumes + engine.set_master_volume(0.8) + engine.set_sound_volume("sfx", 0.7) + engine.set_music_volume("bgm", 0.6) + + # Play audio + engine.play_sound("sfx", "sound") + engine.play_music_stream("bgm", "music") + + import time + time.sleep(0.1) + + # Update music + engine.update_music_stream("bgm") + + # Stop + engine.stop_sound("sfx") + engine.stop_music_stream("bgm") + + # Cleanup + engine.unload_sound("sfx") + engine.unload_music_stream("bgm") + + finally: + engine.close_audio_device() + + +if __name__ == '__main__': + # Run tests + unittest.main(verbosity=2) diff --git a/test/libs/test_global_data.py b/test/libs/test_global_data.py new file mode 100644 index 0000000..10ff3b9 --- /dev/null +++ b/test/libs/test_global_data.py @@ -0,0 +1,551 @@ +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from libs.global_data import ( + Camera, + Crown, + DanResultData, + DanResultExam, + DanResultSong, + Difficulty, + GlobalData, + Modifiers, + PlayerNum, + ResultData, + ScoreMethod, + SessionData, + global_data, + reset_session, +) + + +class TestPlayerNum(unittest.TestCase): + """Test cases for the PlayerNum enum.""" + + def test_player_num_values(self): + """Test PlayerNum enum values.""" + self.assertEqual(PlayerNum.ALL, 0) + self.assertEqual(PlayerNum.P1, 1) + self.assertEqual(PlayerNum.P2, 2) + self.assertEqual(PlayerNum.TWO_PLAYER, 3) + self.assertEqual(PlayerNum.DAN, 4) + + def test_player_num_is_int_enum(self): + """Test that PlayerNum values are integers.""" + self.assertIsInstance(PlayerNum.P1, int) + self.assertIsInstance(PlayerNum.P2, int) + + +class TestScoreMethod(unittest.TestCase): + """Test cases for the ScoreMethod class.""" + + def test_score_method_constants(self): + """Test ScoreMethod constants.""" + self.assertEqual(ScoreMethod.GEN3, "gen3") + self.assertEqual(ScoreMethod.SHINUCHI, "shinuchi") + + +class TestDifficulty(unittest.TestCase): + """Test cases for the Difficulty enum.""" + + def test_difficulty_values(self): + """Test Difficulty enum values.""" + self.assertEqual(Difficulty.EASY, 0) + self.assertEqual(Difficulty.NORMAL, 1) + self.assertEqual(Difficulty.HARD, 2) + self.assertEqual(Difficulty.ONI, 3) + self.assertEqual(Difficulty.URA, 4) + self.assertEqual(Difficulty.TOWER, 5) + self.assertEqual(Difficulty.DAN, 6) + + def test_difficulty_ordering(self): + """Test that difficulty levels are ordered correctly.""" + self.assertLess(Difficulty.EASY, Difficulty.NORMAL) + self.assertLess(Difficulty.NORMAL, Difficulty.HARD) + self.assertLess(Difficulty.HARD, Difficulty.ONI) + self.assertLess(Difficulty.ONI, Difficulty.URA) + + +class TestCrown(unittest.TestCase): + """Test cases for the Crown enum.""" + + def test_crown_values(self): + """Test Crown enum values.""" + self.assertEqual(Crown.NONE, 0) + self.assertEqual(Crown.CLEAR, 1) + self.assertEqual(Crown.FC, 2) + self.assertEqual(Crown.DFC, 3) + + def test_crown_ordering(self): + """Test crown achievement ordering.""" + self.assertLess(Crown.NONE, Crown.CLEAR) + self.assertLess(Crown.CLEAR, Crown.FC) + self.assertLess(Crown.FC, Crown.DFC) + + +class TestModifiers(unittest.TestCase): + """Test cases for the Modifiers dataclass.""" + + def test_default_values(self): + """Test default modifier values.""" + mods = Modifiers() + + self.assertFalse(mods.auto) + self.assertEqual(mods.speed, 1.0) + self.assertFalse(mods.display) + self.assertFalse(mods.inverse) + self.assertEqual(mods.random, 0) + + def test_custom_values(self): + """Test custom modifier values.""" + mods = Modifiers(auto=True, speed=2.0, display=True, inverse=True, random=3) + + self.assertTrue(mods.auto) + self.assertEqual(mods.speed, 2.0) + self.assertTrue(mods.display) + self.assertTrue(mods.inverse) + self.assertEqual(mods.random, 3) + + def test_speed_multiplier(self): + """Test different speed multiplier values.""" + mods1 = Modifiers(speed=0.5) + mods2 = Modifiers(speed=1.5) + mods3 = Modifiers(speed=3.0) + + self.assertEqual(mods1.speed, 0.5) + self.assertEqual(mods2.speed, 1.5) + self.assertEqual(mods3.speed, 3.0) + + +class TestDanResultSong(unittest.TestCase): + """Test cases for the DanResultSong dataclass.""" + + def test_default_values(self): + """Test default DanResultSong values.""" + song = DanResultSong() + + self.assertEqual(song.selected_difficulty, 0) + self.assertEqual(song.diff_level, 0) + self.assertEqual(song.song_title, "default_title") + self.assertEqual(song.genre_index, 0) + self.assertEqual(song.good, 0) + self.assertEqual(song.ok, 0) + self.assertEqual(song.bad, 0) + self.assertEqual(song.drumroll, 0) + + def test_custom_values(self): + """Test custom DanResultSong values.""" + song = DanResultSong( + selected_difficulty=3, + diff_level=10, + song_title="Test Song", + genre_index=5, + good=100, + ok=20, + bad=5, + drumroll=15 + ) + + self.assertEqual(song.selected_difficulty, 3) + self.assertEqual(song.diff_level, 10) + self.assertEqual(song.song_title, "Test Song") + self.assertEqual(song.genre_index, 5) + self.assertEqual(song.good, 100) + self.assertEqual(song.ok, 20) + self.assertEqual(song.bad, 5) + self.assertEqual(song.drumroll, 15) + + +class TestDanResultExam(unittest.TestCase): + """Test cases for the DanResultExam class.""" + + def test_default_values(self): + """Test default DanResultExam values.""" + exam = DanResultExam() + + self.assertEqual(exam.progress, 0) + self.assertEqual(exam.counter_value, 0) + self.assertEqual(exam.bar_texture, "exam_red") + self.assertFalse(exam.failed) + + def test_custom_values(self): + """Test custom DanResultExam values.""" + exam = DanResultExam() + exam.progress = 0.75 + exam.counter_value = 150 + exam.bar_texture = "exam_gold" + exam.failed = True + + self.assertEqual(exam.progress, 0.75) + self.assertEqual(exam.counter_value, 150) + self.assertEqual(exam.bar_texture, "exam_gold") + self.assertTrue(exam.failed) + + +class TestDanResultData(unittest.TestCase): + """Test cases for the DanResultData dataclass.""" + + def test_default_values(self): + """Test default DanResultData values.""" + data = DanResultData() + + self.assertEqual(data.dan_color, 0) + self.assertEqual(data.dan_title, "default_title") + self.assertEqual(data.score, 0) + self.assertEqual(data.gauge_length, 0.0) + self.assertEqual(data.max_combo, 0) + self.assertEqual(data.songs, []) + self.assertEqual(data.exams, []) + self.assertEqual(data.exam_data, []) + + def test_with_songs(self): + """Test DanResultData with songs.""" + song1 = DanResultSong(song_title="Song 1") + song2 = DanResultSong(song_title="Song 2") + + data = DanResultData(songs=[song1, song2]) + + self.assertEqual(len(data.songs), 2) + self.assertEqual(data.songs[0].song_title, "Song 1") + self.assertEqual(data.songs[1].song_title, "Song 2") + + def test_with_exam_data(self): + """Test DanResultData with exam data.""" + exam1 = DanResultExam() + exam1.progress = 0.5 + exam2 = DanResultExam() + exam2.progress = 1.0 + + data = DanResultData(exam_data=[exam1, exam2]) + + self.assertEqual(len(data.exam_data), 2) + self.assertEqual(data.exam_data[0].progress, 0.5) + self.assertEqual(data.exam_data[1].progress, 1.0) + + +class TestResultData(unittest.TestCase): + """Test cases for the ResultData dataclass.""" + + def test_default_values(self): + """Test default ResultData values.""" + data = ResultData() + + self.assertEqual(data.score, 0) + self.assertEqual(data.good, 0) + self.assertEqual(data.ok, 0) + self.assertEqual(data.bad, 0) + self.assertEqual(data.max_combo, 0) + self.assertEqual(data.total_drumroll, 0) + self.assertEqual(data.gauge_length, 0) + self.assertEqual(data.prev_score, 0) + + def test_custom_values(self): + """Test custom ResultData values.""" + data = ResultData( + score=500000, + good=150, + ok=30, + bad=10, + max_combo=120, + total_drumroll=45, + gauge_length=0.85, + prev_score=450000 + ) + + self.assertEqual(data.score, 500000) + self.assertEqual(data.good, 150) + self.assertEqual(data.ok, 30) + self.assertEqual(data.bad, 10) + self.assertEqual(data.max_combo, 120) + self.assertEqual(data.total_drumroll, 45) + self.assertEqual(data.gauge_length, 0.85) + self.assertEqual(data.prev_score, 450000) + + def test_total_notes(self): + """Test calculating total notes from result data.""" + data = ResultData(good=100, ok=50, bad=10) + total = data.good + data.ok + data.bad + + self.assertEqual(total, 160) + + +class TestSessionData(unittest.TestCase): + """Test cases for the SessionData dataclass.""" + + def test_default_values(self): + """Test default SessionData values.""" + session = SessionData() + + self.assertEqual(session.selected_song, Path()) + self.assertEqual(session.song_hash, "") + self.assertEqual(session.selected_dan, []) + self.assertEqual(session.selected_dan_exam, []) + self.assertEqual(session.dan_color, 0) + self.assertEqual(session.selected_difficulty, 0) + self.assertEqual(session.song_title, "default_title") + self.assertEqual(session.genre_index, 0) + self.assertIsInstance(session.result_data, ResultData) + self.assertIsInstance(session.dan_result_data, DanResultData) + + def test_custom_song_selection(self): + """Test custom song selection.""" + song_path = Path("Songs/TestSong/song.tja") + session = SessionData( + selected_song=song_path, + song_hash="abc123", + selected_difficulty=3, + song_title="Test Song" + ) + + self.assertEqual(session.selected_song, song_path) + self.assertEqual(session.song_hash, "abc123") + self.assertEqual(session.selected_difficulty, 3) + self.assertEqual(session.song_title, "Test Song") + + def test_dan_selection(self): + """Test dan course selection.""" + dan_songs = [(Mock(), 0, 3, 10), (Mock(), 1, 3, 10)] + dan_exams = [Mock(), Mock(), Mock()] + + session = SessionData( + selected_dan=dan_songs, + selected_dan_exam=dan_exams, + dan_color=2 + ) + + self.assertEqual(len(session.selected_dan), 2) + self.assertEqual(len(session.selected_dan_exam), 3) + self.assertEqual(session.dan_color, 2) + + def test_result_data_independence(self): + """Test that each session has independent result data.""" + session1 = SessionData() + session2 = SessionData() + + session1.result_data.score = 100000 + + self.assertEqual(session1.result_data.score, 100000) + self.assertEqual(session2.result_data.score, 0) + + +class TestCamera(unittest.TestCase): + """Test cases for the Camera class.""" + + @patch('libs.global_data.ray') + def test_default_values(self, mock_ray): + """Test default Camera values.""" + mock_ray.Vector2 = Mock(return_value=Mock()) + mock_ray.BLACK = Mock() + + camera = Camera() + + self.assertEqual(camera.zoom, 1.0) + self.assertEqual(camera.h_scale, 1.0) + self.assertEqual(camera.v_scale, 1.0) + self.assertEqual(camera.rotation, 0.0) + + +class TestGlobalData(unittest.TestCase): + """Test cases for the GlobalData dataclass.""" + + def test_default_values(self): + """Test default GlobalData values.""" + data = GlobalData() + + self.assertEqual(data.songs_played, 0) + self.assertIsInstance(data.camera, Camera) + self.assertEqual(data.song_hashes, {}) + self.assertEqual(data.song_paths, {}) + self.assertEqual(data.score_db, "") + self.assertEqual(data.song_progress, 0.0) + self.assertEqual(data.total_songs, 0) + self.assertEqual(data.hit_sound, [0, 0, 0]) + self.assertEqual(data.player_num, PlayerNum.P1) + self.assertEqual(data.input_locked, 0) + + def test_modifiers_list(self): + """Test that modifiers list has correct size.""" + data = GlobalData() + + self.assertEqual(len(data.modifiers), 3) + self.assertIsInstance(data.modifiers[0], Modifiers) + self.assertIsInstance(data.modifiers[1], Modifiers) + self.assertIsInstance(data.modifiers[2], Modifiers) + + def test_session_data_list(self): + """Test that session data list has correct size.""" + data = GlobalData() + + self.assertEqual(len(data.session_data), 3) + self.assertIsInstance(data.session_data[0], SessionData) + self.assertIsInstance(data.session_data[1], SessionData) + self.assertIsInstance(data.session_data[2], SessionData) + + def test_song_hashes_dict(self): + """Test song_hashes dictionary operations.""" + data = GlobalData() + + data.song_hashes["hash1"] = [{"path": "Songs/Song1"}] + data.song_hashes["hash2"] = [{"path": "Songs/Song2"}] + + self.assertEqual(len(data.song_hashes), 2) + self.assertIn("hash1", data.song_hashes) + self.assertIn("hash2", data.song_hashes) + + def test_song_paths_dict(self): + """Test song_paths dictionary operations.""" + data = GlobalData() + + path1 = Path("Songs/Song1/song.tja") + path2 = Path("Songs/Song2/song.tja") + + data.song_paths[path1] = "hash1" + data.song_paths[path2] = "hash2" + + self.assertEqual(len(data.song_paths), 2) + self.assertEqual(data.song_paths[path1], "hash1") + self.assertEqual(data.song_paths[path2], "hash2") + + def test_input_locked_counter(self): + """Test input_locked as a counter.""" + data = GlobalData() + + self.assertEqual(data.input_locked, 0) + + data.input_locked += 1 + self.assertEqual(data.input_locked, 1) + + data.input_locked += 1 + self.assertEqual(data.input_locked, 2) + + data.input_locked -= 1 + self.assertEqual(data.input_locked, 1) + + def test_songs_played_counter(self): + """Test songs_played counter.""" + data = GlobalData() + + self.assertEqual(data.songs_played, 0) + + data.songs_played += 1 + self.assertEqual(data.songs_played, 1) + + data.songs_played += 1 + self.assertEqual(data.songs_played, 2) + + def test_hit_sound_indices(self): + """Test hit_sound indices list.""" + data = GlobalData() + + self.assertEqual(data.hit_sound, [0, 0, 0]) + + data.hit_sound[0] = 1 + data.hit_sound[1] = 2 + data.hit_sound[2] = 3 + + self.assertEqual(data.hit_sound, [1, 2, 3]) + + +class TestGlobalDataSingleton(unittest.TestCase): + """Test cases for the global_data singleton.""" + + def test_global_data_exists(self): + """Test that global_data instance exists.""" + self.assertIsInstance(global_data, GlobalData) + + def test_global_data_modifiable(self): + """Test that global_data can be modified.""" + original_songs_played = global_data.songs_played + global_data.songs_played += 1 + + self.assertEqual(global_data.songs_played, original_songs_played + 1) + + # Reset for other tests + global_data.songs_played = original_songs_played + + +class TestResetSession(unittest.TestCase): + """Test cases for reset_session function.""" + + def test_reset_session_clears_p1_data(self): + """Test that reset_session clears player 1 data.""" + global_data.session_data[1].result_data.score = 100000 + global_data.session_data[1].song_title = "Test Song" + + reset_session() + + self.assertIsInstance(global_data.session_data[1], SessionData) + self.assertEqual(global_data.session_data[1].song_title, "default_title") + + def test_reset_session_clears_p2_data(self): + """Test that reset_session clears player 2 data.""" + global_data.session_data[2].result_data.score = 50000 + global_data.session_data[2].selected_difficulty = 3 + + reset_session() + + self.assertIsInstance(global_data.session_data[2], SessionData) + self.assertEqual(global_data.session_data[2].selected_difficulty, 0) + + def test_reset_session_preserves_index_0(self): + """Test that reset_session doesn't affect index 0.""" + original_data = global_data.session_data[0] + original_data.song_title = "Should Not Change" + + reset_session() + + self.assertEqual(global_data.session_data[0].song_title, "Should Not Change") + + def test_reset_session_creates_new_instances(self): + """Test that reset_session creates new SessionData instances.""" + old_p1_session = global_data.session_data[1] + old_p2_session = global_data.session_data[2] + + reset_session() + + self.assertIsNot(global_data.session_data[1], old_p1_session) + self.assertIsNot(global_data.session_data[2], old_p2_session) + + +class TestDataclassIntegration(unittest.TestCase): + """Integration tests for dataclass interactions.""" + + def test_session_with_result_data(self): + """Test SessionData with populated ResultData.""" + session = SessionData() + session.result_data.score = 750000 + session.result_data.good = 200 + session.result_data.max_combo = 180 + + self.assertEqual(session.result_data.score, 750000) + self.assertEqual(session.result_data.good, 200) + self.assertEqual(session.result_data.max_combo, 180) + + def test_session_with_dan_result_data(self): + """Test SessionData with populated DanResultData.""" + session = SessionData() + session.dan_result_data.dan_title = "10th Dan" + session.dan_result_data.dan_color = 5 + + song1 = DanResultSong(song_title="Dan Song 1") + song2 = DanResultSong(song_title="Dan Song 2") + session.dan_result_data.songs = [song1, song2] + + self.assertEqual(session.dan_result_data.dan_title, "10th Dan") + self.assertEqual(len(session.dan_result_data.songs), 2) + + def test_modifiers_independent_per_player(self): + """Test that each player has independent modifiers.""" + data = GlobalData() + + data.modifiers[1].speed = 2.0 + data.modifiers[2].speed = 1.5 + + self.assertEqual(data.modifiers[1].speed, 2.0) + self.assertEqual(data.modifiers[2].speed, 1.5) + self.assertEqual(data.modifiers[0].speed, 1.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/libs/test_global_objects.py b/test/libs/test_global_objects.py new file mode 100644 index 0000000..278fd64 --- /dev/null +++ b/test/libs/test_global_objects.py @@ -0,0 +1,289 @@ +import unittest +from unittest.mock import Mock, patch + +from libs.global_data import PlayerNum +from libs.global_objects import ( + AllNetIcon, + CoinOverlay, + EntryOverlay, + Indicator, + Nameplate, + Timer, +) + + +class TestNameplate(unittest.TestCase): + """Test cases for the Nameplate class.""" + + def setUp(self): + """Set up test fixtures.""" + # Mock global_tex and its methods + self.mock_tex = Mock() + self.mock_tex.skin_config = { + "nameplate_text_name": Mock(font_size=20, x=100, y=50, width=200), + "nameplate_text_title": Mock(font_size=16, x=100, y=80, width=150), + "nameplate_title_offset": Mock(x=10), + "nameplate_dan_offset": Mock(x=20) + } + self.mock_tex.get_animation = Mock(return_value=Mock(start=Mock(), update=Mock(), is_finished=False)) + + @patch('libs.global_objects.global_tex') + @patch('libs.global_objects.OutlinedText') + def test_initialization_basic(self, mock_text, mock_global_tex): + """Test basic nameplate initialization.""" + mock_global_tex.skin_config = self.mock_tex.skin_config + + nameplate = Nameplate("TestPlayer", "TestTitle", PlayerNum.P1, 5, False, False, 0) + + self.assertEqual(nameplate.dan_index, 5) + self.assertEqual(nameplate.player_num, 1) + self.assertFalse(nameplate.is_gold) + self.assertFalse(nameplate.is_rainbow) + self.assertEqual(nameplate.title_bg, 0) + + @patch('libs.global_objects.global_tex') + @patch('libs.global_objects.OutlinedText') + def test_initialization_rainbow(self, mock_text, mock_global_tex): + """Test rainbow nameplate initialization.""" + mock_global_tex.skin_config = self.mock_tex.skin_config + mock_animation = Mock() + mock_global_tex.get_animation.return_value = mock_animation + + nameplate = Nameplate("Player", "Title", PlayerNum.P1, 3, False, True, 0) + + self.assertTrue(nameplate.is_rainbow) + mock_global_tex.get_animation.assert_called_once_with(12) + mock_animation.start.assert_called_once() + + @patch('libs.global_objects.global_tex') + def test_update_rainbow_animation(self, mock_global_tex): + """Test rainbow animation update logic.""" + mock_animation = Mock(is_finished=False, update=Mock()) + mock_global_tex.get_animation.return_value = mock_animation + mock_global_tex.skin_config = self.mock_tex.skin_config + + with patch('libs.global_objects.OutlinedText'): + nameplate = Nameplate("P", "T", PlayerNum.P1, 0, False, True, 0) + nameplate.update(1000.0) + + mock_animation.update.assert_called_once_with(1000.0) + + @patch('libs.global_objects.global_tex') + def test_update_rainbow_restart(self, mock_global_tex): + """Test rainbow animation restarts when finished.""" + mock_animation = Mock(is_finished=True, update=Mock(), restart=Mock()) + mock_global_tex.get_animation.return_value = mock_animation + mock_global_tex.skin_config = self.mock_tex.skin_config + + with patch('libs.global_objects.OutlinedText'): + nameplate = Nameplate("P", "T", PlayerNum.P1, 0, False, True, 0) + nameplate.update(1000.0) + + mock_animation.restart.assert_called_once() + + @patch('libs.global_objects.global_tex') + def test_unload(self, mock_global_tex): + """Test nameplate resource cleanup.""" + mock_global_tex.skin_config = self.mock_tex.skin_config + + with patch('libs.global_objects.OutlinedText') as mock_text: + mock_name = Mock() + mock_title = Mock() + mock_text.side_effect = [mock_name, mock_title] + + nameplate = Nameplate("P", "T", PlayerNum.P1, 0, False, False, 0) + nameplate.unload() + + mock_name.unload.assert_called_once() + mock_title.unload.assert_called_once() + + +class TestIndicator(unittest.TestCase): + """Test cases for the Indicator class.""" + + @patch('libs.global_objects.global_tex') + @patch('libs.global_objects.OutlinedText') + def test_initialization(self, mock_text, mock_global_tex): + """Test indicator initialization with different states.""" + mock_global_tex.get_animation.return_value = Mock() + mock_global_tex.skin_config = {"indicator_text": Mock(text={"en": "Select"}, font_size=20)} + + with patch('libs.global_objects.global_data') as mock_data: + mock_data.config = {"general": {"language": "en"}} + + indicator = Indicator(Indicator.State.SELECT) + + self.assertEqual(indicator.state, Indicator.State.SELECT) + self.assertEqual(mock_global_tex.get_animation.call_count, 3) + + @patch('libs.global_objects.global_tex') + def test_update_animations(self, mock_global_tex): + """Test that all animations update correctly.""" + mock_don_fade = Mock() + mock_arrow_move = Mock() + mock_arrow_fade = Mock() + mock_global_tex.get_animation.side_effect = [mock_don_fade, mock_arrow_move, mock_arrow_fade] + mock_global_tex.skin_config = {"indicator_text": Mock(text={"en": "S"}, font_size=20)} + + with patch('libs.global_objects.global_data.config', {"general": {"language": "en"}}): + with patch('libs.global_objects.OutlinedText'): + indicator = Indicator(Indicator.State.SKIP) + indicator.update(500.0) + + mock_don_fade.update.assert_called_once_with(500.0) + mock_arrow_move.update.assert_called_once_with(500.0) + mock_arrow_fade.update.assert_called_once_with(500.0) + + +class TestTimer(unittest.TestCase): + """Test cases for the Timer class.""" + + @patch('libs.global_objects.get_config') + @patch('libs.global_objects.global_tex') + def test_initialization(self, mock_tex, mock_config): + """Test timer initialization.""" + mock_config.return_value = {"general": {"timer_frozen": False}} + mock_tex.get_animation.return_value = Mock() + mock_func = Mock() + + timer = Timer(30, 0.0, mock_func) + + self.assertEqual(timer.time, 30) + self.assertEqual(timer.counter, "30") + self.assertFalse(timer.is_finished) + self.assertFalse(timer.is_frozen) + + @patch('libs.global_objects.audio') + @patch('libs.global_objects.get_config') + @patch('libs.global_objects.global_tex') + def test_countdown_normal(self, mock_tex, mock_config, mock_audio): + """Test normal countdown behavior.""" + mock_config.return_value = {"general": {"timer_frozen": False}} + mock_tex.get_animation.return_value = Mock(update=Mock(), start=Mock()) + mock_func = Mock() + + timer = Timer(15, 0.0, mock_func) + timer.update(1000.0) + + self.assertEqual(timer.time, 14) + self.assertEqual(timer.counter, "14") + + @patch('libs.global_objects.audio') + @patch('libs.global_objects.get_config') + @patch('libs.global_objects.global_tex') + def test_countdown_below_ten(self, mock_tex, mock_config, mock_audio): + """Test countdown triggers animations below 10.""" + mock_config.return_value = {"general": {"timer_frozen": False}} + mock_animation = Mock(update=Mock(), start=Mock()) + mock_tex.get_animation.return_value = mock_animation + mock_func = Mock() + + timer = Timer(10, 0.0, mock_func) + timer.update(1000.0) + + self.assertEqual(timer.time, 9) + mock_audio.play_sound.assert_called_with('timer_blip', 'sound') + self.assertEqual(mock_animation.start.call_count, 3) + + @patch('libs.global_objects.audio') + @patch('libs.global_objects.get_config') + @patch('libs.global_objects.global_tex') + def test_voice_triggers(self, mock_tex, mock_config, mock_audio): + """Test voice announcements at specific times.""" + mock_config.return_value = {"general": {"timer_frozen": False}} + mock_tex.get_animation.return_value = Mock(update=Mock(), start=Mock()) + mock_func = Mock() + + # Test 10 second voice + timer = Timer(11, 0.0, mock_func) + timer.update(1000.0) + mock_audio.play_sound.assert_called_with('voice_timer_10', 'voice') + + # Test 5 second voice + timer = Timer(6, 0.0, mock_func) + timer.update(1000.0) + mock_audio.play_sound.assert_called_with('voice_timer_5', 'voice') + + @patch('libs.global_objects.audio') + @patch('libs.global_objects.get_config') + @patch('libs.global_objects.global_tex') + def test_timer_finish_callback(self, mock_tex, mock_config, mock_audio): + """Test callback is triggered when timer reaches zero.""" + mock_config.return_value = {"general": {"timer_frozen": False}} + mock_tex.get_animation.return_value = Mock(update=Mock(), start=Mock()) + mock_audio.is_sound_playing.return_value = False + mock_func = Mock() + + timer = Timer(1, 0.0, mock_func) + timer.update(1000.0) + timer.update(2000.0) + + mock_func.assert_called_once() + self.assertTrue(timer.is_finished) + + @patch('libs.global_objects.get_config') + @patch('libs.global_objects.global_tex') + def test_timer_frozen(self, mock_tex, mock_config): + """Test frozen timer doesn't count down.""" + mock_config.return_value = {"general": {"timer_frozen": True}} + mock_tex.get_animation.return_value = Mock(update=Mock()) + mock_func = Mock() + + timer = Timer(10, 0.0, mock_func) + initial_time = timer.time + timer.update(1000.0) + + self.assertEqual(timer.time, initial_time) + + +class TestCoinOverlay(unittest.TestCase): + """Test cases for the CoinOverlay class.""" + + @patch('libs.global_objects.global_tex') + @patch('libs.global_objects.global_data') + @patch('libs.global_objects.OutlinedText') + def test_initialization(self, mock_text, mock_data, mock_tex): + """Test coin overlay initialization.""" + mock_tex.skin_config = { + "free_play": Mock(text={"en": "Free Play"}, font_size=24, y=100) + } + mock_data.config = {"general": {"language": "en"}} + + _ = CoinOverlay() + + mock_text.assert_called_once() + + +class TestAllNetIcon(unittest.TestCase): + """Test cases for the AllNetIcon class.""" + + @patch('libs.global_objects.get_config') + def test_initialization_offline(self, mock_config): + """Test AllNet icon initializes offline.""" + mock_config.return_value = {"general": {"fake_online": False}} + + icon = AllNetIcon() + + self.assertFalse(icon.online) + + @patch('libs.global_objects.get_config') + def test_initialization_online(self, mock_config): + """Test AllNet icon initializes online.""" + mock_config.return_value = {"general": {"fake_online": True}} + + icon = AllNetIcon() + + self.assertTrue(icon.online) + + +class TestEntryOverlay(unittest.TestCase): + """Test cases for the EntryOverlay class.""" + + @patch('libs.global_objects.get_config') + def test_initialization(self, mock_config): + """Test entry overlay initialization.""" + mock_config.return_value = {"general": {"fake_online": False}} + + overlay = EntryOverlay() + + self.assertFalse(overlay.online) diff --git a/test/libs/test_screen.py b/test/libs/test_screen.py new file mode 100644 index 0000000..fba78ec --- /dev/null +++ b/test/libs/test_screen.py @@ -0,0 +1,333 @@ +import unittest +from unittest.mock import Mock, patch + +from libs.screen import Screen + + +class TestScreen(unittest.TestCase): + """Test cases for the Screen class.""" + + def setUp(self): + """Set up test fixtures.""" + self.screen_name = "test_screen" + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_initialization(self, mock_audio, mock_tex): + """Test screen initialization.""" + screen = Screen(self.screen_name) + + self.assertEqual(screen.screen_name, self.screen_name) + self.assertFalse(screen.screen_init) + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_on_screen_start(self, mock_audio, mock_tex): + """Test on_screen_start loads textures and sounds.""" + screen = Screen(self.screen_name) + screen.on_screen_start() + + mock_tex.load_screen_textures.assert_called_once_with(self.screen_name) + mock_audio.load_screen_sounds.assert_called_once_with(self.screen_name) + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_do_screen_start_first_call(self, mock_audio, mock_tex): + """Test _do_screen_start initializes screen on first call.""" + screen = Screen(self.screen_name) + + self.assertFalse(screen.screen_init) + screen._do_screen_start() + + self.assertTrue(screen.screen_init) + mock_tex.load_screen_textures.assert_called_once_with(self.screen_name) + mock_audio.load_screen_sounds.assert_called_once_with(self.screen_name) + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_do_screen_start_subsequent_calls(self, mock_audio, mock_tex): + """Test _do_screen_start doesn't reinitialize on subsequent calls.""" + screen = Screen(self.screen_name) + + screen._do_screen_start() + screen._do_screen_start() + screen._do_screen_start() + + # Should only be called once despite multiple calls + mock_tex.load_screen_textures.assert_called_once() + mock_audio.load_screen_sounds.assert_called_once() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_on_screen_end(self, mock_audio, mock_tex): + """Test on_screen_end unloads resources and returns next screen.""" + screen = Screen(self.screen_name) + screen.screen_init = True + + next_screen = "next_screen" + result = screen.on_screen_end(next_screen) + + self.assertEqual(result, next_screen) + self.assertFalse(screen.screen_init) + mock_audio.unload_all_sounds.assert_called_once() + mock_audio.unload_all_music.assert_called_once() + mock_tex.unload_textures.assert_called_once() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_on_screen_end_unload_order(self, mock_audio, mock_tex): + """Test that resources are unloaded in correct order.""" + screen = Screen(self.screen_name) + screen.screen_init = True + + manager = Mock() + manager.attach_mock(mock_audio.unload_all_sounds, 'unload_sounds') + manager.attach_mock(mock_audio.unload_all_music, 'unload_music') + manager.attach_mock(mock_tex.unload_textures, 'unload_textures') + + screen.on_screen_end("next") + + # Verify order: sounds, music, then textures + calls = manager.mock_calls + self.assertEqual(calls[0][0], 'unload_sounds') + self.assertEqual(calls[1][0], 'unload_music') + self.assertEqual(calls[2][0], 'unload_textures') + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_update_not_initialized(self, mock_audio, mock_tex): + """Test update initializes screen if not already initialized.""" + screen = Screen(self.screen_name) + + self.assertFalse(screen.screen_init) + screen.update() + + self.assertTrue(screen.screen_init) + mock_tex.load_screen_textures.assert_called_once() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_update_already_initialized(self, mock_audio, mock_tex): + """Test update doesn't reinitialize if already initialized.""" + screen = Screen(self.screen_name) + screen.screen_init = True + + screen.update() + + # Should not load again + mock_tex.load_screen_textures.assert_not_called() + mock_audio.load_screen_sounds.assert_not_called() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_update_returns_value(self, mock_audio, mock_tex): + """Test update returns value from _do_screen_start.""" + screen = Screen(self.screen_name) + + with patch.object(screen, '_do_screen_start', return_value="test_value"): + result = screen.update() + self.assertEqual(result, "test_value") + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_draw_default_implementation(self, mock_audio, mock_tex): + """Test draw has empty default implementation.""" + screen = Screen(self.screen_name) + + # Should not raise any errors + screen.draw() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_do_draw_when_initialized(self, mock_audio, mock_tex): + """Test _do_draw calls draw when screen is initialized.""" + screen = Screen(self.screen_name) + screen.screen_init = True + + with patch.object(screen, 'draw') as mock_draw: + screen._do_draw() + mock_draw.assert_called_once() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_do_draw_when_not_initialized(self, mock_audio, mock_tex): + """Test _do_draw doesn't call draw when screen is not initialized.""" + screen = Screen(self.screen_name) + screen.screen_init = False + + with patch.object(screen, 'draw') as mock_draw: + screen._do_draw() + mock_draw.assert_not_called() + + +class TestScreenSubclass(unittest.TestCase): + """Test cases for Screen subclass behavior.""" + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_subclass_custom_on_screen_start(self, mock_audio, mock_tex): + """Test that subclass can override on_screen_start.""" + class CustomScreen(Screen): + def __init__(self, name): + super().__init__(name) + self.custom_init_called = False + + def on_screen_start(self): + super().on_screen_start() + self.custom_init_called = True + + screen = CustomScreen("custom") + screen.on_screen_start() + + self.assertTrue(screen.custom_init_called) + mock_tex.load_screen_textures.assert_called_once_with("custom") + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_subclass_custom_update(self, mock_audio, mock_tex): + """Test that subclass can override update.""" + class CustomScreen(Screen): + def __init__(self, name): + super().__init__(name) + self.update_count = 0 + + def update(self): + result = super().update() + self.update_count += 1 + return result + + screen = CustomScreen("custom") + screen.update() + screen.update() + + self.assertEqual(screen.update_count, 2) + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_subclass_custom_draw(self, mock_audio, mock_tex): + """Test that subclass can override draw.""" + class CustomScreen(Screen): + def __init__(self, name): + super().__init__(name) + self.draw_called = False + + def draw(self): + self.draw_called = True + + screen = CustomScreen("custom") + screen.screen_init = True + screen._do_draw() + + self.assertTrue(screen.draw_called) + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_subclass_custom_on_screen_end(self, mock_audio, mock_tex): + """Test that subclass can override on_screen_end.""" + class CustomScreen(Screen): + def __init__(self, name): + super().__init__(name) + self.cleanup_called = False + + def on_screen_end(self, next_screen): + self.cleanup_called = True + return super().on_screen_end(next_screen) + + screen = CustomScreen("custom") + screen.screen_init = True + result = screen.on_screen_end("next") + + self.assertTrue(screen.cleanup_called) + self.assertEqual(result, "next") + + +class TestScreenLifecycle(unittest.TestCase): + """Test cases for complete screen lifecycle.""" + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_full_lifecycle(self, mock_audio, mock_tex): + """Test complete screen lifecycle from start to end.""" + screen = Screen("lifecycle_test") + + # Initial state + self.assertFalse(screen.screen_init) + + # Start screen + screen.update() + self.assertTrue(screen.screen_init) + mock_tex.load_screen_textures.assert_called_once_with("lifecycle_test") + mock_audio.load_screen_sounds.assert_called_once_with("lifecycle_test") + + # Multiple updates don't reinitialize + screen.update() + screen.update() + self.assertEqual(mock_tex.load_screen_textures.call_count, 1) + + # Draw while initialized + with patch.object(screen, 'draw') as mock_draw: + screen._do_draw() + mock_draw.assert_called_once() + + # End screen + result = screen.on_screen_end("next_screen") + self.assertEqual(result, "next_screen") + self.assertFalse(screen.screen_init) + mock_audio.unload_all_sounds.assert_called_once() + mock_audio.unload_all_music.assert_called_once() + mock_tex.unload_textures.assert_called_once() + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_multiple_screen_transitions(self, mock_audio, mock_tex): + """Test transitioning between multiple screens.""" + screen1 = Screen("screen1") + screen2 = Screen("screen2") + screen3 = Screen("screen3") + + # Initialize first screen + screen1.update() + self.assertTrue(screen1.screen_init) + + # Transition to second screen + next_name = screen1.on_screen_end("screen2") + self.assertEqual(next_name, "screen2") + self.assertFalse(screen1.screen_init) + + screen2.update() + self.assertTrue(screen2.screen_init) + + # Transition to third screen + next_name = screen2.on_screen_end("screen3") + self.assertEqual(next_name, "screen3") + self.assertFalse(screen2.screen_init) + + screen3.update() + self.assertTrue(screen3.screen_init) + + @patch('libs.screen.tex') + @patch('libs.screen.audio') + def test_screen_reinitialize_after_end(self, mock_audio, mock_tex): + """Test that screen can be reinitialized after ending.""" + screen = Screen("reinit_test") + + # First initialization + screen.update() + self.assertTrue(screen.screen_init) + + # End screen + screen.on_screen_end("next") + self.assertFalse(screen.screen_init) + + # Reinitialize + mock_tex.load_screen_textures.reset_mock() + mock_audio.load_screen_sounds.reset_mock() + + screen.update() + self.assertTrue(screen.screen_init) + mock_tex.load_screen_textures.assert_called_once() + mock_audio.load_screen_sounds.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/test/libs/test_texture.py b/test/libs/test_texture.py new file mode 100644 index 0000000..1f9bbd3 --- /dev/null +++ b/test/libs/test_texture.py @@ -0,0 +1,289 @@ +import unittest +from unittest.mock import Mock, patch + +from libs.texture import ( + FramedTexture, + SkinInfo, + Texture, + TextureWrapper, +) + + +class TestSkinInfo(unittest.TestCase): + """Test cases for the SkinInfo dataclass.""" + + def test_initialization(self): + """Test SkinInfo initialization.""" + skin_info = SkinInfo( + x=100.0, + y=200.0, + font_size=24, + width=300.0, + height=100.0, + text={"en": "Test", "ja": "テスト"} + ) + + self.assertEqual(skin_info.x, 100.0) + self.assertEqual(skin_info.y, 200.0) + self.assertEqual(skin_info.font_size, 24) + self.assertEqual(skin_info.width, 300.0) + self.assertEqual(skin_info.height, 100.0) + self.assertEqual(skin_info.text, {"en": "Test", "ja": "テスト"}) + + def test_repr(self): + """Test SkinInfo string representation.""" + skin_info = SkinInfo( + x=100.0, + y=200.0, + font_size=24, + width=300.0, + height=100.0, + text={"en": "Test"} + ) + + repr_str = repr(skin_info) + self.assertIn("100.0", repr_str) + self.assertIn("200.0", repr_str) + + +class TestTexture(unittest.TestCase): + """Test cases for the Texture class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_texture = Mock() + self.mock_texture.width = 100 + self.mock_texture.height = 50 + + @patch('libs.texture.ray') + def test_initialization_single_texture(self, mock_ray): + """Test Texture initialization with single texture.""" + texture = Texture( + name="test_texture", + texture=self.mock_texture, + init_vals={} + ) + + self.assertEqual(texture.name, "test_texture") + self.assertEqual(texture.texture, self.mock_texture) + self.assertEqual(texture.width, 100) + self.assertEqual(texture.height, 50) + self.assertEqual(texture.x, [0]) + self.assertEqual(texture.y, [0]) + + @patch('libs.texture.ray') + def test_initialization_with_init_vals(self, mock_ray): + """Test Texture initialization with init_vals.""" + init_vals = {"x": 10, "y": 20} + texture = Texture( + name="test", + texture=self.mock_texture, + init_vals=init_vals + ) + + self.assertEqual(texture.init_vals, init_vals) + self.assertEqual(texture.name, "test") + + @patch('libs.texture.ray') + def test_default_values(self, mock_ray): + """Test Texture default values.""" + texture = Texture(name="test", texture=self.mock_texture, init_vals={}) + + self.assertEqual(texture.x, [0]) + self.assertEqual(texture.y, [0]) + self.assertEqual(texture.x2, [100]) + self.assertEqual(texture.y2, [50]) + self.assertEqual(texture.controllable, [False]) + + @patch('libs.texture.ray') + def test_repr(self, mock_ray): + """Test Texture string representation.""" + texture = Texture(name="test", texture=self.mock_texture, init_vals={}) + + repr_str = repr(texture) + self.assertIn("test", repr_str) + + +class TestFramedTexture(unittest.TestCase): + """Test cases for the FramedTexture class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_textures = [Mock() for _ in range(4)] + for tex in self.mock_textures: + tex.width = 200 + tex.height = 100 + + @patch('libs.texture.ray') + def test_initialization(self, mock_ray): + """Test FramedTexture initialization.""" + framed = FramedTexture( + name="test_framed", + texture=self.mock_textures, + init_vals={} + ) + + self.assertEqual(framed.name, "test_framed") + self.assertEqual(framed.texture, self.mock_textures) + self.assertEqual(framed.width, 200) + self.assertEqual(framed.height, 100) + self.assertEqual(framed.x, [0]) + self.assertEqual(framed.y, [0]) + + @patch('libs.texture.ray') + def test_default_values(self, mock_ray): + """Test FramedTexture default values.""" + framed = FramedTexture( + name="test", + texture=self.mock_textures, + init_vals={} + ) + + self.assertEqual(framed.x, [0]) + self.assertEqual(framed.y, [0]) + self.assertEqual(framed.x2, [200]) + self.assertEqual(framed.y2, [100]) + + +class TestTextureWrapper(unittest.TestCase): + """Test cases for the TextureWrapper class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_texture = Mock() + self.mock_texture.width = 100 + self.mock_texture.height = 50 + + @patch('libs.texture.get_config') + @patch('libs.texture.Path') + def test_initialization(self, mock_path_cls, mock_get_config): + """Test TextureWrapper initialization.""" + mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}} + + # Mock the skin_config.json file + mock_path_instance = Mock() + mock_config_path = Mock() + mock_config_path.exists.return_value = True + mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}' + mock_path_instance.__truediv__ = Mock(return_value=mock_config_path) + mock_path_cls.return_value = mock_path_instance + + wrapper = TextureWrapper() + + self.assertEqual(wrapper.screen_width, 1280) + self.assertEqual(wrapper.screen_height, 720) + self.assertIsInstance(wrapper.textures, dict) + + @patch('libs.texture.get_config') + @patch('libs.texture.Path') + def test_get_animation(self, mock_path_cls, mock_get_config): + """Test getting animation from list.""" + mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}} + + # Mock the skin_config.json file + mock_path_instance = Mock() + mock_config_path = Mock() + mock_config_path.exists.return_value = True + mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}' + mock_path_instance.__truediv__ = Mock(return_value=mock_config_path) + mock_path_cls.return_value = mock_path_instance + + mock_animation = Mock() + + wrapper = TextureWrapper() + wrapper.animations = {0: mock_animation} + + result = wrapper.get_animation(0) + + self.assertEqual(result, mock_animation) + + @patch('libs.texture.get_config') + @patch('libs.texture.Path') + @patch('libs.texture.copy.deepcopy') + def test_get_animation_copy(self, mock_deepcopy, mock_path_cls, mock_get_config): + """Test getting animation copy.""" + mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}} + + # Mock the skin_config.json file + mock_path_instance = Mock() + mock_config_path = Mock() + mock_config_path.exists.return_value = True + mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}' + mock_path_instance.__truediv__ = Mock(return_value=mock_config_path) + mock_path_cls.return_value = mock_path_instance + + mock_animation = Mock() + mock_copy = Mock() + mock_deepcopy.return_value = mock_copy + + wrapper = TextureWrapper() + wrapper.animations = {0: mock_animation} + + result = wrapper.get_animation(0, is_copy=True) + + mock_deepcopy.assert_called_once_with(mock_animation) + self.assertEqual(result, mock_copy) + + @patch('libs.texture.get_config') + @patch('libs.texture.Path') + @patch('libs.texture.ray') + def test_read_tex_obj_data(self, mock_ray, mock_path_cls, mock_get_config): + """Test reading texture object data from JSON.""" + mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}} + + # Mock the skin_config.json file + mock_path_instance = Mock() + mock_config_path = Mock() + mock_config_path.exists.return_value = True + mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}' + + mock_path_instance.__truediv__ = Mock(return_value=mock_config_path) + mock_path_cls.return_value = mock_path_instance + + wrapper = TextureWrapper() + + # Create a mock texture object + mock_texture = Mock() + mock_texture.x = [0] + mock_texture.y = [0] + + # Test with a dictionary mapping + tex_mapping = {"x": 10, "y": 20} + wrapper._read_tex_obj_data(tex_mapping, mock_texture) + + # Verify the texture attributes were updated (they are lists) + self.assertEqual(mock_texture.x, [10]) + self.assertEqual(mock_texture.y, [20]) + + @patch('libs.texture.get_config') + @patch('libs.texture.Path') + def test_read_tex_obj_data_not_exists(self, mock_path_cls, mock_get_config): + """Test reading texture data with empty mapping.""" + mock_get_config.return_value = {'paths': {'skin': 'TestSkin'}} + + # Mock the skin_config.json file + mock_path_instance = Mock() + mock_config_path = Mock() + mock_config_path.exists.return_value = True + mock_config_path.read_text.return_value = '{"screen": {"width": 1280, "height": 720}}' + + mock_path_instance.__truediv__ = Mock(return_value=mock_config_path) + mock_path_cls.return_value = mock_path_instance + + wrapper = TextureWrapper() + + # Create a mock texture object + mock_texture = Mock() + mock_texture.x = [0] + mock_texture.y = [0] + + # Test with empty mapping (should not modify texture) + tex_mapping = {} + wrapper._read_tex_obj_data(tex_mapping, mock_texture) + + # Verify the texture attributes remained unchanged + self.assertEqual(mock_texture.x, [0]) + self.assertEqual(mock_texture.y, [0]) + +if __name__ == '__main__': + unittest.main() diff --git a/uv.lock b/uv.lock index 57d3f40..b07d5ae 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -83,6 +83,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "nuitka" version = "2.8.4" @@ -102,6 +120,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -111,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pypresence" version = "4.6.1" @@ -122,11 +167,12 @@ wheels = [ [[package]] name = "pytaiko" -version = "1.0" +version = "1.1" source = { virtual = "." } dependencies = [ { name = "av" }, { name = "pypresence" }, + { name = "pytest" }, { name = "raylib-sdl" }, { name = "tomlkit" }, ] @@ -140,6 +186,7 @@ dev = [ requires-dist = [ { name = "av", specifier = ">=16.0.1" }, { name = "pypresence", specifier = ">=4.6.1" }, + { name = "pytest", specifier = ">=9.0.2" }, { name = "raylib-sdl", specifier = ">=5.5.0.2" }, { name = "tomlkit", specifier = ">=0.13.3" }, ] @@ -147,6 +194,22 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "nuitka", specifier = ">=2.8.4" }] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "raylib-sdl" version = "5.5.0.3"