Source code for picogl.tests.test_glmesh

"""
Unit tests for the GLMesh class in the PicoGL OpenGL backend.

This module contains a comprehensive suite of unit tests for verifying the correctness,
robustness, and interface of the :class:`picogl.renderer.glmesh.GLMesh`
class, which manages GPU-resident mesh data and rendering.

The tests cover:

- Object initialization with various mesh data
- from_mesh_data class method
- GPU buffer upload and allocation
- OpenGL binding/unbinding operations
- Drawing operations
- Context manager functionality
- Resource cleanup and memory management
- Error handling and edge cases
- Data validation and type conversion

Dependencies:
    - unittest (standard library)
    - unittest.mock.MagicMock for OpenGL function mocking
    - numpy for test data
    - picogl.renderer.glmesh.GLMesh
    - picogl.renderer.meshdata.MeshData

To run the tests::

    python -m unittest picogl.tests.test_glmesh

"""

import unittest
from unittest.mock import MagicMock, patch, call

import numpy as np
from OpenGL.raw.GL.VERSION.GL_1_0 import GL_TRIANGLES, GL_UNSIGNED_INT

from picogl.renderer.glmesh import GLMesh
from picogl.renderer.meshdata import MeshData


[docs] class TestGLMesh(unittest.TestCase): """Test cases for GLMesh class."""
[docs] def setUp(self): """Set up test fixtures before each test method.""" # Create test data self.test_vertices = np.array([ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0] ], dtype=np.float32) self.test_faces = np.array([0, 1, 2, 1, 3, 2], dtype=np.uint32) self.test_colors = np.array([ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 0.0] ], dtype=np.float32) self.test_normals = np.array([ [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0] ], dtype=np.float32) self.test_uvs = np.array([ [0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0] ], dtype=np.float32) # Mock OpenGL functions to avoid context issues self.gl_patches = [ patch('picogl.renderer.glmesh.glDrawElements'), patch('picogl.renderer.glmesh.delete_buffer_object'), # Mock VertexArrayObject and its methods patch('picogl.renderer.glmesh.VertexArrayObject'), ] # Start all patches for patch_obj in self.gl_patches: patch_obj.start()
[docs] def tearDown(self): """Clean up after each test method.""" # Stop all patches for patch_obj in self.gl_patches: patch_obj.stop()
[docs] def test_initialization_with_all_parameters(self): """Test GLMesh initialization with all parameters.""" mesh = GLMesh( vertices=self.test_vertices, faces=self.test_faces, colors=self.test_colors, normals=self.test_normals, uvs=self.test_uvs ) # Test data assignments np.testing.assert_array_equal(mesh.vertices, self.test_vertices) np.testing.assert_array_equal(mesh.indices, self.test_faces) np.testing.assert_array_equal(mesh.colors, self.test_colors) np.testing.assert_array_equal(mesh.normals, self.test_normals) np.testing.assert_array_equal(mesh.uvs, self.test_uvs) # Test initial state self.assertIsNone(mesh.vao) self.assertEqual(mesh.index_count, 0)
[docs] def test_initialization_with_minimal_parameters(self): """Test GLMesh initialization with only vertices and faces.""" mesh = GLMesh( vertices=self.test_vertices, faces=self.test_faces ) # Test data assignments np.testing.assert_array_equal(mesh.vertices, self.test_vertices) np.testing.assert_array_equal(mesh.indices, self.test_faces) # Test default values expected_colors = np.tile((0.0, 0.0, 1.0), (4, 1)).astype(np.float32) np.testing.assert_array_equal(mesh.colors, expected_colors) expected_normals = np.zeros_like(self.test_vertices) np.testing.assert_array_equal(mesh.normals, expected_normals) expected_uvs = np.zeros((4, 2), dtype=np.float32) np.testing.assert_array_equal(mesh.uvs, expected_uvs) # Test initial state self.assertIsNone(mesh.vao) self.assertEqual(mesh.index_count, 0)
[docs] def test_initialization_with_empty_faces(self): """Test GLMesh initialization with empty faces raises error.""" with self.assertRaises(ValueError) as context: GLMesh( vertices=self.test_vertices, faces=np.array([], dtype=np.uint32) ) self.assertIn("GLMesh requires non-empty faces", str(context.exception))
[docs] def test_initialization_with_none_faces(self): """Test GLMesh initialization with None faces raises error.""" with self.assertRaises(TypeError) as context: GLMesh( vertices=self.test_vertices, faces=None ) # The actual error is from numpy trying to convert None to uint32 self.assertIn("int() argument must be a string", str(context.exception))
[docs] def test_initialization_data_type_conversion(self): """Test that data is properly converted to correct types.""" # Test with different input types int_vertices = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int32) int_faces = np.array([0, 1], dtype=np.int32) mesh = GLMesh(vertices=int_vertices, faces=int_faces) # Should be converted to float32 and uint32 self.assertEqual(mesh.vertices.dtype, np.float32) self.assertEqual(mesh.indices.dtype, np.uint32)
[docs] def test_initialization_array_reshaping(self): """Test that arrays are properly reshaped.""" # Test with 1D arrays vertices_1d = np.array([0, 0, 0, 1, 0, 0, 0, 1, 0], dtype=np.float32) faces_1d = np.array([0, 1, 2], dtype=np.uint32) mesh = GLMesh(vertices=vertices_1d, faces=faces_1d) # Should be reshaped to (N, 3) and (M,) self.assertEqual(mesh.vertices.shape, (3, 3)) self.assertEqual(mesh.indices.shape, (3,))
[docs] def test_from_mesh_data_with_all_attributes(self): """Test from_mesh_data with all MeshData attributes.""" mesh_data = MeshData( vbo=self.test_vertices, ebo=self.test_faces, cbo=self.test_colors, nbo=self.test_normals, uvs=self.test_uvs ) glmesh = GLMesh.from_mesh_data(mesh_data) # Test data assignments np.testing.assert_array_equal(glmesh.vertices, self.test_vertices) np.testing.assert_array_equal(glmesh.indices, self.test_faces) np.testing.assert_array_equal(glmesh.colors, self.test_colors) np.testing.assert_array_equal(glmesh.normals, self.test_normals) np.testing.assert_array_equal(glmesh.uvs, self.test_uvs)
[docs] def test_from_mesh_data_with_minimal_attributes(self): """Test from_mesh_data with minimal MeshData attributes.""" mesh_data = MeshData( vbo=self.test_vertices, ebo=self.test_faces ) glmesh = GLMesh.from_mesh_data(mesh_data) # Test data assignments np.testing.assert_array_equal(glmesh.vertices, self.test_vertices) np.testing.assert_array_equal(glmesh.indices, self.test_faces) # Test default values expected_colors = np.tile((0.0, 0.0, 1.0), (4, 1)).astype(np.float32) np.testing.assert_array_equal(glmesh.colors, expected_colors)
[docs] def test_from_mesh_data_without_uvs(self): """Test from_mesh_data without UVs attribute.""" mesh_data = MeshData( vbo=self.test_vertices, ebo=self.test_faces, cbo=self.test_colors, nbo=self.test_normals ) # Remove uvs attribute delattr(mesh_data, 'uvs') glmesh = GLMesh.from_mesh_data(mesh_data) # Should use default UVs expected_uvs = np.zeros((4, 2), dtype=np.float32) np.testing.assert_array_equal(glmesh.uvs, expected_uvs)
[docs] def test_upload_first_time(self): """Test upload method for the first time.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VertexArrayObject mock_vao = MagicMock() with patch('picogl.renderer.glmesh.VertexArrayObject', return_value=mock_vao): mesh.upload() # Test VAO assignment self.assertEqual(mesh.vao, mock_vao) self.assertEqual(mesh.index_count, len(self.test_faces)) # Test VAO methods were called mock_vao.add_vbo.assert_called() mock_vao.add_ebo.assert_called_once()
[docs] def test_upload_already_uploaded(self): """Test upload method when already uploaded.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO mock_vao = MagicMock() mesh.vao = mock_vao # Upload again mesh.upload() # Should not create new VAO self.assertEqual(mesh.vao, mock_vao)
[docs] def test_upload_with_uvs(self): """Test upload method with UVs.""" mesh = GLMesh( vertices=self.test_vertices, faces=self.test_faces, uvs=self.test_uvs ) # Mock VertexArrayObject mock_vao = MagicMock() with patch('picogl.renderer.glmesh.VertexArrayObject', return_value=mock_vao): mesh.upload() # Test that UV VBO was added calls = mock_vao.add_vbo.call_args_list uv_call_found = any( len(call[1]) > 0 and call[1].get('index') == 3 and call[1].get('size') == 2 for call in calls ) self.assertTrue(uv_call_found)
[docs] def test_upload_without_uvs(self): """Test upload method without UVs.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Set UVs to None to test the condition mesh.uvs = None # Mock VertexArrayObject mock_vao = MagicMock() with patch('picogl.renderer.glmesh.VertexArrayObject', return_value=mock_vao): mesh.upload() # Test that UV VBO was not added (should have 3 calls: vertices, colors, normals) calls = mock_vao.add_vbo.call_args_list self.assertEqual(len(calls), 3) # Only vertices, colors, normals # Verify no UV call (index 3) uv_call_found = any( len(call[1]) > 0 and call[1].get('index') == 3 for call in calls ) self.assertFalse(uv_call_found)
[docs] def test_bind_with_uploaded_mesh(self): """Test bind method with uploaded mesh.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO mock_vao = MagicMock() mesh.vao = mock_vao mesh.bind() # Test VAO context manager was called mock_vao.__enter__.assert_called_once()
[docs] def test_bind_without_upload(self): """Test bind method without upload raises error.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) with self.assertRaises(RuntimeError) as context: mesh.bind() self.assertIn("GLMesh not uploaded", str(context.exception))
[docs] def test_unbind_with_vao(self): """Test unbind method with VAO.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO mock_vao = MagicMock() mesh.vao = mock_vao mesh.unbind() # Test VAO context manager exit was called mock_vao.__exit__.assert_called_once_with(None, None, None)
[docs] def test_unbind_without_vao(self): """Test unbind method without VAO.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Should not raise error mesh.unbind()
[docs] def test_delete_with_vao(self): """Test delete method with VAO.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO mock_vao = MagicMock() mesh.vao = mock_vao mesh.index_count = 10 mesh.delete() # Test VAO was deleted and state was reset self.assertIsNone(mesh.vao) self.assertEqual(mesh.index_count, 0)
[docs] def test_delete_without_vao(self): """Test delete method without VAO.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Should not raise error mesh.delete()
[docs] def test_context_manager(self): """Test that GLMesh can be used as a context manager.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO mock_vao = MagicMock() mesh.vao = mock_vao # Test context manager usage with mesh as context_mesh: self.assertEqual(context_mesh, mesh) mock_vao.__enter__.assert_called_once() mock_vao.__exit__.assert_called_once_with(None, None, None)
[docs] def test_draw_with_uploaded_mesh(self): """Test draw method with uploaded mesh.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO mock_vao = MagicMock() mesh.vao = mock_vao mesh.index_count = len(self.test_faces) with patch('picogl.renderer.glmesh.glDrawElements') as mock_draw: mesh.draw() # Test VAO context manager was used mock_vao.__enter__.assert_called_once() mock_vao.__exit__.assert_called_once() # Test draw call was made mock_draw.assert_called_once_with( GL_TRIANGLES, len(self.test_faces), GL_UNSIGNED_INT, mock_draw.call_args[0][3] # ctypes.c_void_p(0) )
[docs] def test_draw_without_upload(self): """Test draw method without upload handles error.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # The draw method catches exceptions and prints them instead of raising with patch('builtins.print') as mock_print: mesh.draw() # Should print the error message mock_print.assert_called_once()
[docs] def test_draw_with_exception_handling(self): """Test draw method with exception handling.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VAO that raises exception mock_vao = MagicMock() mock_vao.__enter__.side_effect = Exception("OpenGL error") mesh.vao = mock_vao mesh.index_count = len(self.test_faces) # Should not raise exception, but print it with patch('builtins.print') as mock_print: mesh.draw() mock_print.assert_called_once()
[docs] def test_vertex_count_calculation(self): """Test vertex count calculation.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Test initial count self.assertEqual(mesh.index_count, 0) # Test after upload mock_vao = MagicMock() with patch('picogl.renderer.glmesh.VertexArrayObject', return_value=mock_vao): mesh.upload() self.assertEqual(mesh.index_count, len(self.test_faces))
[docs] def test_data_validation(self): """Test data validation and type checking.""" # Test with valid data mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) self.assertEqual(mesh.vertices.shape[1], 3) # Should be (N, 3) self.assertEqual(mesh.indices.ndim, 1) # Should be 1D # Test with different vertex counts vertices_2 = np.array([[0, 0, 0], [1, 1, 1]], dtype=np.float32) faces_2 = np.array([0, 1], dtype=np.uint32) mesh_2 = GLMesh(vertices=vertices_2, faces=faces_2) self.assertEqual(mesh_2.vertices.shape[0], 2) self.assertEqual(mesh_2.colors.shape[0], 2) # Colors should match vertex count
[docs] def test_default_color_generation(self): """Test default colour generation.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Should generate blue colors for all vertices expected_colors = np.tile((0.0, 0.0, 1.0), (4, 1)).astype(np.float32) np.testing.assert_array_equal(mesh.colors, expected_colors)
[docs] def test_default_normal_generation(self): """Test default normal generation.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Should generate zero normals expected_normals = np.zeros_like(self.test_vertices) np.testing.assert_array_equal(mesh.normals, expected_normals)
[docs] def test_default_uv_generation(self): """Test default UV generation.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Should generate zero UVs expected_uvs = np.zeros((4, 2), dtype=np.float32) np.testing.assert_array_equal(mesh.uvs, expected_uvs)
[docs] def test_mesh_data_integration(self): """Test integration with MeshData class.""" # Create MeshData mesh_data = MeshData.from_raw( vertices=self.test_vertices, indices=self.test_faces, colors=self.test_colors, normals=self.test_normals, uvs=self.test_uvs ) # Create GLMesh from MeshData glmesh = GLMesh.from_mesh_data(mesh_data) # Test data consistency - GLMesh reshapes data to (N, 3) while MeshData keeps it flat np.testing.assert_array_equal(glmesh.vertices.reshape(-1), mesh_data.vbo) np.testing.assert_array_equal(glmesh.indices, mesh_data.ebo) np.testing.assert_array_equal(glmesh.colors.reshape(-1), mesh_data.cbo) np.testing.assert_array_equal(glmesh.normals.reshape(-1), mesh_data.nbo) np.testing.assert_array_equal(glmesh.uvs.reshape(-1), mesh_data.uvs)
[docs] def test_upload_vbo_parameters(self): """Test that VBOs are added with correct parameters.""" mesh = GLMesh( vertices=self.test_vertices, faces=self.test_faces, colors=self.test_colors, normals=self.test_normals, uvs=self.test_uvs ) # Mock VertexArrayObject mock_vao = MagicMock() with patch('picogl.renderer.glmesh.VertexArrayObject', return_value=mock_vao): mesh.upload() # Test VBO calls calls = mock_vao.add_vbo.call_args_list # Should have 4 VBO calls (vertices, colors, normals, uvs) self.assertEqual(len(calls), 4) # Test vertex VBO (index 0, size 3) vertex_call = next(call for call in calls if call[1].get('index') == 0) self.assertEqual(vertex_call[1]['size'], 3) np.testing.assert_array_equal(vertex_call[1]['data'], self.test_vertices) # Test colour VBO (index 1, size 3) color_call = next(call for call in calls if call[1].get('index') == 1) self.assertEqual(color_call[1]['size'], 3) np.testing.assert_array_equal(color_call[1]['data'], self.test_colors) # Test normal VBO (index 2, size 3) normal_call = next(call for call in calls if call[1].get('index') == 2) self.assertEqual(normal_call[1]['size'], 3) np.testing.assert_array_equal(normal_call[1]['data'], self.test_normals) # Test UV VBO (index 3, size 2) uv_call = next(call for call in calls if call[1].get('index') == 3) self.assertEqual(uv_call[1]['size'], 2) np.testing.assert_array_equal(uv_call[1]['data'], self.test_uvs)
[docs] def test_ebo_parameters(self): """Test that EBO is added with correct parameters.""" mesh = GLMesh(vertices=self.test_vertices, faces=self.test_faces) # Mock VertexArrayObject mock_vao = MagicMock() with patch('picogl.renderer.glmesh.VertexArrayObject', return_value=mock_vao): mesh.upload() # Test EBO call - verify it was called once mock_vao.add_ebo.assert_called_once() # Test the data parameter separately to avoid array comparison issues call_args = mock_vao.add_ebo.call_args np.testing.assert_array_equal(call_args[1]['data'], self.test_faces)
if __name__ == "__main__": unittest.main()