Source code for picogl.tests.test_vertex_buffer_group

"""
Unit tests for the VertexBufferGroup 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.buffers.vertex.legacy.VertexBufferGroup`
class, which manages legacy OpenGL vertex buffer objects and mimics VAO functionality.

The tests cover:

- Object initialization and setup
- VBO management and attribute configuration
- EBO (Element Buffer Object) management
- Layout descriptor configuration
- Drawing operations with various modes
- Buffer cleanup and memory management
- Error handling and edge cases
- Legacy client state management

Dependencies:
    - unittest (standard library)
    - unittest.mock.MagicMock for OpenGL function mocking
    - numpy for test data
    - picogl.buffers.vertex.legacy.VertexBufferGroup
    - picogl.buffers.attributes.LayoutDescriptor

To run the tests::

    python -m unittest picogl.tests.test_vertex_buffer_group

"""

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

import numpy as np
from OpenGL.raw.GL._types import GL_FLOAT, GL_UNSIGNED_INT
from OpenGL.raw.GL.VERSION.GL_1_0 import GL_POINTS, GL_TRIANGLES
from OpenGL.raw.GL.VERSION.GL_1_1 import GL_COLOR_ARRAY, GL_NORMAL_ARRAY, GL_VERTEX_ARRAY
from OpenGL.raw.GL.VERSION.GL_1_5 import GL_ARRAY_BUFFER, GL_ELEMENT_ARRAY_BUFFER

from picogl.buffers.vertex.legacy import VertexBufferGroup
from picogl.buffers.attributes import LayoutDescriptor, AttributeSpec


[docs] class TestVertexBufferGroup(unittest.TestCase): """Test cases for VertexBufferGroup class."""
[docs] def setUp(self): """Set up test fixtures before each test method.""" self.test_data = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32) self.test_indices = np.array([0, 1, 2], dtype=np.uint32) self.test_colors = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float32) # Mock OpenGL functions to avoid context issues self.gl_patches = [ patch('picogl.buffers.vertex.legacy.glDrawArrays'), patch('picogl.buffers.vertex.legacy.glDrawElements'), patch('picogl.buffers.vertex.legacy.glBindBuffer'), patch('picogl.buffers.vertex.legacy.glEnableVertexAttribArray'), patch('picogl.buffers.vertex.legacy.glDisableVertexAttribArray'), patch('picogl.buffers.vertex.legacy.glVertexAttribPointer'), patch('picogl.buffers.vertex.legacy.legacy_client_states'), patch('picogl.buffers.vertex.legacy.delete_buffer_object'), # Mock OpenGL functions that might be called during VBO creation patch('picogl.backend.legacy.core.vertex.buffer.vertex.glGenBuffers'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glBufferData'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glBindBuffer'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glEnableClientState'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glDisableClientState'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glVertexPointer'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glColorPointer'), patch('picogl.backend.legacy.core.vertex.buffer.vertex.glNormalPointer'), # Mock client states module patch('picogl.backend.legacy.core.vertex.buffer.client_states.glEnableClientState'), patch('picogl.backend.legacy.core.vertex.buffer.client_states.glDisableClientState'), patch('picogl.backend.legacy.core.vertex.buffer.client_states.glBindBuffer'), ] # 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(self): """Test VertexBufferGroup initialization.""" vbg = VertexBufferGroup() # Test initial state self.assertEqual(vbg.handle, 0) self.assertIsNone(vbg.vao) self.assertIsNone(vbg.vbo) self.assertIsNone(vbg.cbo) self.assertIsNone(vbg.nbo) self.assertIsNone(vbg.ebo) self.assertIsNone(vbg.layout) self.assertEqual(vbg.named_vbos, {}) # Test VBO classes mapping expected_classes = ["vbo", "cbo", "ebo", "nbo"] for class_name in expected_classes: self.assertIn(class_name, vbg.vbo_classes)
[docs] def test_add_vbo_object(self): """Test add_vbo_object method for VBO management.""" vbg = VertexBufferGroup() mock_vbo = MagicMock() mock_vbo._id = 123 # Test adding VBO with canonical name result = vbg.add_vbo_object("position", mock_vbo) self.assertEqual(result, mock_vbo) self.assertEqual(vbg.named_vbos["position"], mock_vbo) # Test adding VBO with alias (if NAME_ALIASES contains mappings) mock_vbo2 = MagicMock() mock_vbo2._id = 456 result2 = vbg.add_vbo_object("pos", mock_vbo2) self.assertEqual(result2, mock_vbo2)
[docs] def test_get_vbo_object(self): """Test get_vbo_object method for VBO retrieval.""" vbg = VertexBufferGroup() mock_vbo = MagicMock() vbg.named_vbos["position"] = mock_vbo result = vbg.get_vbo_object("position") self.assertEqual(result, mock_vbo) # Test with non-existent name result_none = vbg.get_vbo_object("nonexistent") self.assertIsNone(result_none)
[docs] def test_index_count_property(self): """Test index_count property.""" vbg = VertexBufferGroup() # Test with no EBO self.assertEqual(vbg.index_count, 0) # Test with EBO containing data mock_ebo = MagicMock() mock_ebo.data = self.test_indices vbg.ebo = mock_ebo self.assertEqual(vbg.index_count, len(self.test_indices)) # Test with EBO but no data attribute mock_ebo_no_data = MagicMock() del mock_ebo_no_data.data # Remove data attribute vbg.ebo = mock_ebo_no_data self.assertEqual(vbg.index_count, 0)
[docs] def test_add_vbo(self): """Test add_vbo method with different buffer types.""" vbg = VertexBufferGroup() # Mock the get_buffer_class method to return a mock class mock_vbo_class = MagicMock() mock_vbo = MagicMock() mock_vbo_class.return_value = mock_vbo with patch.object(vbg, 'get_buffer_class', return_value=mock_vbo_class): # Test adding position VBO with default parameters result = vbg.add_vbo("vbo", data=self.test_data) # Verify get_buffer_class was called vbg.get_buffer_class.assert_called_once_with("vbo") # Verify VBO was created and added with default parameters mock_vbo_class.assert_called_once_with( data=self.test_data, size=3, handle=None, dtype=GL_FLOAT ) self.assertEqual(result, mock_vbo) self.assertEqual(vbg.named_vbos["vbo"], mock_vbo)
[docs] def test_add_vbo_with_custom_parameters(self): """Test add_vbo method with custom parameters.""" vbg = VertexBufferGroup() # Mock the get_buffer_class method to return a mock class mock_vbo_class = MagicMock() mock_vbo = MagicMock() mock_vbo_class.return_value = mock_vbo with patch.object(vbg, 'get_buffer_class', return_value=mock_vbo_class): # Test adding VBO with custom parameters custom_handle = 123 custom_size = 4 custom_dtype = GL_UNSIGNED_INT result = vbg.add_vbo( "vbo", data=self.test_data, size=custom_size, handle=custom_handle, dtype=custom_dtype ) # Verify get_buffer_class was called vbg.get_buffer_class.assert_called_once_with("vbo") # Verify VBO was created with custom parameters mock_vbo_class.assert_called_once_with( data=self.test_data, size=custom_size, handle=custom_handle, dtype=custom_dtype ) self.assertEqual(result, mock_vbo) self.assertEqual(vbg.named_vbos["vbo"], mock_vbo)
[docs] def test_add_vbo_invalid_parameters(self): """Test add_vbo method with invalid parameters.""" vbg = VertexBufferGroup() # Test with None data with self.assertRaises(ValueError): vbg.add_vbo("vbo", data=None) # Test with size <= 0 with self.assertRaises(ValueError): vbg.add_vbo("vbo", data=self.test_data, size=0) with self.assertRaises(ValueError): vbg.add_vbo("vbo", data=self.test_data, size=-1)
[docs] def test_add_ebo(self): """Test add_ebo method.""" vbg = VertexBufferGroup() # Mock the EBO class in the vbo_classes dictionary mock_ebo_class = MagicMock() mock_ebo = MagicMock() mock_ebo_class.return_value = mock_ebo # Replace the EBO class in vbo_classes original_ebo_class = vbg.vbo_classes["ebo"] vbg.vbo_classes["ebo"] = mock_ebo_class try: vbg.add_ebo("ebo", data=self.test_indices) mock_ebo_class.assert_called_once_with(data=self.test_indices) self.assertEqual(vbg.named_vbos["ebo"], mock_ebo) finally: # Restore original class vbg.vbo_classes["ebo"] = original_ebo_class
[docs] def test_get_buffer_class(self): """Test get_buffer_class method.""" vbg = VertexBufferGroup() # Test known buffer types self.assertEqual(vbg.get_buffer_class("vbo"), vbg.vbo_classes["vbo"]) self.assertEqual(vbg.get_buffer_class("cbo"), vbg.vbo_classes["cbo"]) self.assertEqual(vbg.get_buffer_class("ebo"), vbg.vbo_classes["ebo"]) self.assertEqual(vbg.get_buffer_class("nbo"), vbg.vbo_classes["nbo"]) # Test unknown buffer type (should return default) self.assertEqual(vbg.get_buffer_class("unknown"), vbg.vbo_classes["vbo"])
[docs] def test_draw_with_arrays(self): """Test draw method with vertex arrays.""" vbg = VertexBufferGroup() # Add some mock VBOs mock_vbo1 = MagicMock() mock_vbo2 = MagicMock() vbg.named_vbos = {"vbo": mock_vbo1, "cbo": mock_vbo2} # Mock the context manager with patch('picogl.buffers.vertex.legacy.legacy_client_states') as mock_client_states: mock_client_states.return_value.__enter__ = MagicMock() mock_client_states.return_value.__exit__ = MagicMock() vbg.draw(index_count=10, mode=GL_POINTS) # Verify VBOs were bound mock_vbo1.bind.assert_called_once() mock_vbo2.bind.assert_called_once() # Verify client states were used mock_client_states.assert_called_once_with(GL_VERTEX_ARRAY, GL_COLOR_ARRAY, GL_NORMAL_ARRAY)
[docs] def test_draw_with_auto_index_count(self): """Test draw method with automatic index count.""" vbg = VertexBufferGroup() # Set up EBO with data mock_ebo = MagicMock() mock_ebo.data = self.test_indices vbg.ebo = mock_ebo # Add some mock VBOs mock_vbo = MagicMock() vbg.named_vbos = {"vbo": mock_vbo} with patch('picogl.buffers.vertex.legacy.legacy_client_states') as mock_client_states: mock_client_states.return_value.__enter__ = MagicMock() mock_client_states.return_value.__exit__ = MagicMock() vbg.draw() # No index_count provided # Should use EBO data length mock_vbo.bind.assert_called_once()
[docs] def test_draw_elements(self): """Test draw_elements method.""" vbg = VertexBufferGroup() # Set up EBO mock_ebo = MagicMock() mock_ebo._id = 123 vbg.ebo = mock_ebo # Add some mock VBOs mock_vbo = MagicMock() vbg.named_vbos = {"vbo": mock_vbo} with patch('picogl.buffers.vertex.legacy.legacy_client_states') as mock_client_states: mock_client_states.return_value.__enter__ = MagicMock() mock_client_states.return_value.__exit__ = MagicMock() vbg.draw_elements(count=5, mode=GL_TRIANGLES) # Verify VBOs were bound mock_vbo.bind.assert_called_once() # Verify client states were used mock_client_states.assert_called_once_with(GL_VERTEX_ARRAY, GL_COLOR_ARRAY, GL_NORMAL_ARRAY)
[docs] def test_draw_elements_without_ebo(self): """Test draw_elements method without EBO raises error.""" vbg = VertexBufferGroup() with self.assertRaises(RuntimeError) as context: vbg.draw_elements() self.assertIn("No element buffer (EBO) bound", str(context.exception))
[docs] def test_draw_elements_with_auto_count(self): """Test draw_elements method with automatic count.""" vbg = VertexBufferGroup() # Set up EBO with data mock_ebo = MagicMock() mock_ebo._id = 123 mock_ebo.data = self.test_indices vbg.ebo = mock_ebo # Add some mock VBOs mock_vbo = MagicMock() vbg.named_vbos = {"vbo": mock_vbo} with patch('picogl.buffers.vertex.legacy.legacy_client_states') as mock_client_states: mock_client_states.return_value.__enter__ = MagicMock() mock_client_states.return_value.__exit__ = MagicMock() vbg.draw_elements() # No count provided # Should use EBO data length mock_vbo.bind.assert_called_once()
[docs] def test_set_layout(self): """Test set_layout method.""" vbg = VertexBufferGroup() layout = LayoutDescriptor(attributes=[]) vbg.set_layout(layout) self.assertEqual(vbg.layout, layout)
[docs] def test_bind_with_layout(self): """Test bind method with layout.""" vbg = VertexBufferGroup() # Create a mock layout descriptor attr_spec = AttributeSpec( name="position", index=0, size=3, type=GL_FLOAT, normalized=False, stride=0, offset=0 ) layout = LayoutDescriptor(attributes=[attr_spec]) vbg.layout = layout # Add mock VBO mock_vbo = MagicMock() mock_vbo._id = 123 vbg.named_vbos["position"] = mock_vbo vbg.bind()
# The bind method calls glBindBuffer and glVertexAttribPointer # which are already mocked in setUp, so we just verify no errors occurred
[docs] def test_bind_without_layout(self): """Test bind method without layout.""" vbg = VertexBufferGroup() # Should return early without processing vbg.bind()
# No assertions needed - should not raise any errors
[docs] def test_bind_with_missing_vbo(self): """Test bind method with missing VBO in layout.""" vbg = VertexBufferGroup() # Create a layout with attribute that has no corresponding VBO attr_spec = AttributeSpec( name="nonexistent", index=0, size=3, type=GL_FLOAT, normalized=False, stride=0, offset=0 ) layout = LayoutDescriptor(attributes=[attr_spec]) vbg.layout = layout # Should not raise error, just skip missing VBO vbg.bind()
[docs] def test_unbind_with_layout(self): """Test unbind method with layout.""" vbg = VertexBufferGroup() # Create a mock layout descriptor attr_spec = AttributeSpec( name="position", index=0, size=3, type=GL_FLOAT, normalized=False, stride=0, offset=0 ) layout = LayoutDescriptor(attributes=[attr_spec]) vbg.layout = layout vbg.unbind()
# The unbind method should disable vertex attrib arrays # This is already mocked in setUp
[docs] def test_unbind_without_layout(self): """Test unbind method without layout.""" vbg = VertexBufferGroup() # Should return early without processing vbg.unbind()
# No assertions needed - should not raise any errors
[docs] def test_delete(self): """Test delete method.""" vbg = VertexBufferGroup() # Add some mock buffers mock_vbo = MagicMock() mock_cbo = MagicMock() mock_nbo = MagicMock() mock_ebo = MagicMock() vbg.vbo = mock_vbo vbg.cbo = mock_cbo vbg.nbo = mock_nbo vbg.ebo = mock_ebo # Set up layout layout = LayoutDescriptor(attributes=[]) vbg.layout = layout vbg.delete() # Verify buffers were deleted # The delete_buffer_object calls are mocked in setUp # Verify internal state was cleared self.assertIsNone(vbg.nbo) self.assertIsNone(vbg.cbo) self.assertIsNone(vbg.vbo) self.assertIsNone(vbg.ebo) self.assertIsNone(vbg.layout)
[docs] def test_delete_with_none_buffers(self): """Test delete method with None buffers.""" vbg = VertexBufferGroup() # All buffers are None by default vbg.delete() # Should not raise any errors self.assertIsNone(vbg.vbo) self.assertIsNone(vbg.cbo) self.assertIsNone(vbg.nbo) self.assertIsNone(vbg.ebo)
[docs] def test_context_manager(self): """Test that VertexBufferGroup can be used as a context manager.""" vbg = VertexBufferGroup() # Test that __enter__ and __exit__ methods exist self.assertTrue(hasattr(vbg, "__enter__")) self.assertTrue(hasattr(vbg, "__exit__")) # Test context manager usage with patch.object(vbg, 'bind') as mock_bind, patch.object(vbg, 'unbind') as mock_unbind: with vbg as context_vbg: self.assertEqual(context_vbg, vbg) mock_bind.assert_called_once() mock_unbind.assert_called_once()
[docs] def test_error_handling_in_add_vbo(self): """Test error handling in add_vbo method.""" vbg = VertexBufferGroup() # Mock an exception during VBO creation by mocking get_buffer_class mock_vbo_class = MagicMock() mock_vbo_class.side_effect = Exception("VBO creation error") with patch.object(vbg, 'get_buffer_class', return_value=mock_vbo_class): # The new add_vbo method should let exceptions propagate with self.assertRaises(Exception) as context: vbg.add_vbo("vbo", data=self.test_data) # Verify the exception message self.assertIn("VBO creation error", str(context.exception))
[docs] def test_error_handling_in_bind(self): """Test error handling in bind method.""" vbg = VertexBufferGroup() # Create a layout that will cause an error attr_spec = AttributeSpec( name="position", index=0, size=3, type=GL_FLOAT, normalized=False, stride=0, offset=0 ) layout = LayoutDescriptor(attributes=[attr_spec]) vbg.layout = layout # Add mock VBO that will cause an error mock_vbo = MagicMock() mock_vbo._id = 123 vbg.named_vbos["position"] = mock_vbo # Mock glVertexAttribPointer to raise an exception with patch('picogl.buffers.vertex.legacy.glVertexAttribPointer', side_effect=Exception("OpenGL error")): with patch('picogl.logger.Logger.error') as mock_log_error: vbg.bind() mock_log_error.assert_called_once() self.assertIn("error", mock_log_error.call_args[0][0])
[docs] def test_type_conversion_in_bind(self): """Test type conversion handling in bind method.""" vbg = VertexBufferGroup() # Create a layout with various type scenarios attr_spec = AttributeSpec( name="position", index=0, size=3, type=GL_FLOAT, normalized=False, stride=0, offset=0 ) layout = LayoutDescriptor(attributes=[attr_spec]) vbg.layout = layout # Add mock VBO mock_vbo = MagicMock() mock_vbo._id = 123 vbg.named_vbos["position"] = mock_vbo # Test with different type scenarios with patch('picogl.buffers.vertex.legacy.glVertexAttribPointer') as mock_vertex_attrib: vbg.bind() # Should call glVertexAttribPointer with converted types mock_vertex_attrib.assert_called_once()
[docs] def test_repr_string(self): """Test string representation of VertexBufferGroup.""" vbg = VertexBufferGroup() repr_str = repr(vbg) self.assertIn("VertexBufferGroup", repr_str) # The default repr doesn't include specific attributes, just test that it's a valid repr self.assertTrue(repr_str.startswith('<')) self.assertTrue(repr_str.endswith('>'))
[docs] def test_vbo_classes_mapping(self): """Test VBO classes mapping is correct.""" vbg = VertexBufferGroup() expected_mappings = { "vbo": "LegacyPositionVBO", "cbo": "LegacyColorVBO", "ebo": "LegacyEBO", "nbo": "LegacyNormalVBO" } for key, expected_class_name in expected_mappings.items(): vbo_class = vbg.vbo_classes[key] self.assertEqual(vbo_class.__name__, expected_class_name)
[docs] def test_legacy_client_states_integration(self): """Test integration with legacy client states.""" vbg = VertexBufferGroup() # Add some mock VBOs mock_vbo = MagicMock() vbg.named_vbos = {"vbo": mock_vbo} with patch('picogl.buffers.vertex.legacy.legacy_client_states') as mock_client_states: mock_client_states.return_value.__enter__ = MagicMock() mock_client_states.return_value.__exit__ = MagicMock() vbg.draw() # Verify legacy client states were called with correct parameters mock_client_states.assert_called_once_with(GL_VERTEX_ARRAY, GL_COLOR_ARRAY, GL_NORMAL_ARRAY)
if __name__ == "__main__": unittest.main()