Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,39 @@ jobs:
- name: Run root build script
run: bash scripts/build-all.sh

unit-tests:
needs: [validation]
name: "Unit Tests"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 17
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Run unit tests
run: bash scripts/run-tests.sh
- name: Publish Test Results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: vendordep/build/test-results/**/*.xml
check_name: Unit Test Results
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: vendordep/build/test-results/

build-docker:
needs: [validation]
strategy:
Expand Down Expand Up @@ -126,6 +159,12 @@ jobs:
- name: Test with Gradle
working-directory: vendordep
run: ./gradlew test ${{ matrix.build-options }}
- name: Publish Docker Build Test Results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: vendordep/build/test-results/**/*.xml
check_name: "Test Results - ${{ matrix.artifact-name }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
Expand Down Expand Up @@ -173,14 +212,20 @@ jobs:
- name: Test with Gradle
working-directory: vendordep
run: ./gradlew test -Pbuildalldesktop
- name: Publish Host Build Test Results
if: always()
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: vendordep/build/test-results/**/*.xml
check_name: "Test Results - ${{ matrix.artifact-name }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: vendordep/build/allOutputs

combine:
name: Combine
needs: [build-docker, build-host]
needs: [build-docker, build-host, unit-tests]
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
Expand Down
142 changes: 129 additions & 13 deletions apps/tests/test_apps.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,133 @@
# Copyright (c) 2026 JSim Contributors
# SPDX-License-Identifier: LGPL-3.0-or-later

"""Unit tests for simulator runtime and viewer components.

Tests simulation loop initialization, camera matrices, and viewer operations.
"""

import pytest
from apps.sim_runtime.main_loop import SimLoop
from viewer_plugin.camera import Camera

def test_sim_loop_init():
loop = SimLoop(tick_rate_hz=100.0)
assert loop.tick_rate_hz == 100.0
assert loop.dt == 0.01
assert loop._running is False

def test_camera_matrices():
cam = Camera()
cam.update_position(1.0, 2.0, 3.0)
assert cam.position == [1.0, 2.0, 3.0]
# Verify matrices return lists safely without crashing
assert isinstance(cam.get_view_matrix(), list)
assert isinstance(cam.get_projection_matrix(), list)

# ---- Simulation Loop Tests --------------------------------------------------

class TestSimulationLoop:
"""Tests for the main simulation loop."""

def test_sim_loop_initialization_with_default_rate(self):
"""SimLoop should initialize with correct tick rate and timestep."""
tick_rate = 100.0
loop = SimLoop(tick_rate_hz=tick_rate)

assert loop.tick_rate_hz == tick_rate, \
f"Expected tick_rate_hz {tick_rate}, got {loop.tick_rate_hz}"
assert loop.dt == 0.01, \
f"Expected dt=0.01 for 100 Hz, got {loop.dt}"
assert loop._running is False, "SimLoop should start in stopped state"

@pytest.mark.parametrize("tick_rate,expected_dt", [
(50.0, 0.02),
(100.0, 0.01),
(200.0, 0.005),
(500.0, 0.002),
])
def test_sim_loop_timestep_calculation(self, tick_rate, expected_dt):
"""SimLoop should calculate correct timestep for various tick rates."""
loop = SimLoop(tick_rate_hz=tick_rate)

assert loop.dt == pytest.approx(expected_dt, abs=1e-9), \
f"Expected dt={expected_dt} for {tick_rate}Hz, got {loop.dt}"

def test_sim_loop_starts_stopped(self):
"""SimLoop should start in stopped state."""
loop = SimLoop(tick_rate_hz=100.0)
assert loop._running is False, "Loop should not be running initially"

def test_sim_loop_multiple_instances_independent(self):
"""Multiple SimLoop instances should be independent."""
loop1 = SimLoop(tick_rate_hz=50.0)
loop2 = SimLoop(tick_rate_hz=200.0)

assert loop1.dt == pytest.approx(0.02, abs=1e-9)
assert loop2.dt == pytest.approx(0.005, abs=1e-9)


# ---- Camera Tests -----------------------------------------------------------

class TestCamera:
"""Tests for viewer camera operations."""

@pytest.fixture
def camera(self):
"""Provide a fresh camera instance for each test."""
return Camera()

def test_camera_position_update(self, camera):
"""Camera position should update correctly."""
x, y, z = 1.0, 2.0, 3.0
camera.update_position(x, y, z)

assert camera.position == [x, y, z], \
f"Expected position [{x}, {y}, {z}], got {camera.position}"

def test_camera_position_list_type(self, camera):
"""Camera position should be stored as a list."""
camera.update_position(1.0, 2.0, 3.0)
assert isinstance(camera.position, list), \
f"Expected position to be list, got {type(camera.position)}"
assert len(camera.position) == 3, \
f"Expected position length 3, got {len(camera.position)}"

def test_camera_view_matrix_returns_list(self, camera):
"""Camera view matrix should return a list without crashing."""
matrix = camera.get_view_matrix()
assert isinstance(matrix, list), \
f"Expected view matrix to be list, got {type(matrix)}"
assert len(matrix) > 0, "View matrix should not be empty"

def test_camera_projection_matrix_returns_list(self, camera):
"""Camera projection matrix should return a list without crashing."""
matrix = camera.get_projection_matrix()
assert isinstance(matrix, list), \
f"Expected projection matrix to be list, got {type(matrix)}"
assert len(matrix) > 0, "Projection matrix should not be empty"

def test_camera_matrices_after_position_update(self, camera):
"""Camera matrices should update after position change."""
camera.update_position(1.0, 2.0, 3.0)
matrix1 = camera.get_view_matrix()

camera.update_position(4.0, 5.0, 6.0)
matrix2 = camera.get_view_matrix()

# Matrices should be different after position update
assert matrix1 != matrix2, \
"View matrix should change when camera position changes"

def test_camera_multiple_position_updates(self, camera):
"""Camera should handle multiple sequential position updates."""
positions = [
(0.0, 0.0, 0.0),
(1.0, 1.0, 1.0),
(-1.0, -1.0, -1.0),
(5.0, 10.0, 15.0),
]

for x, y, z in positions:
camera.update_position(x, y, z)
assert camera.position == [x, y, z], \
f"Position not updated correctly to ({x}, {y}, {z})"

def test_camera_zero_position(self, camera):
"""Camera should handle zero position correctly."""
camera.update_position(0.0, 0.0, 0.0)
assert camera.position == [0.0, 0.0, 0.0], \
"Camera should accept zero position"

def test_camera_negative_position(self, camera):
"""Camera should handle negative position coordinates."""
camera.update_position(-1.0, -2.0, -3.0)
assert camera.position == [-1.0, -2.0, -3.0], \
"Camera should accept negative coordinates"
Loading
Loading