1- """Unit tests for Groundlight.ask_vlm — mocks HTTP, no live server needed."""
1+ """Unit tests for Groundlight.ask_vlm — all HTTP mocked , no live server needed."""
22
3+ from unittest import mock
34from unittest .mock import MagicMock , patch
45
56import numpy as np
67import pytest
78from groundlight import Groundlight , VLMVerificationResult
89
910
10- @pytest .fixture
11- def gl (monkeypatch ):
11+ @pytest .fixture ( name = "gl" )
12+ def groundlight_fixture (monkeypatch ) -> Groundlight :
1213 monkeypatch .setenv ("GROUNDLIGHT_API_TOKEN" , "api_fake_test_token" )
13- # Avoid the live /v1/me connectivity check performed during __init__.
1414 with patch .object (Groundlight , "_verify_connectivity" , return_value = None ):
15- client = Groundlight (endpoint = "http://test-server/device-api/" )
16- return client
15+ return Groundlight (endpoint = "http://test-server/device-api/" )
1716
1817
19- def _mock_response (
20- verdict = "YES" , confidence = 0.92 , reasoning = "Flames visible." , model_id = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
21- ):
18+ def _mock_response (verdict = "YES" , confidence = 0.92 , reasoning = "Flames visible." , model_id = "gpt-5.4" ):
2219 resp = MagicMock ()
2320 resp .status_code = 201
2421 resp .json .return_value = {
@@ -34,94 +31,106 @@ def _mock_response(
3431 return resp
3532
3633
37- class TestAskVlm :
38- @ patch ( "groundlight.client.requests" )
39- def test_returns_vlm_verification_result ( self , mock_requests , gl ) :
34+ def test_returns_vlm_verification_result ( gl : Groundlight ) :
35+ """ask_vlm returns a typed VLMVerificationResult with all expected fields populated."""
36+ with mock . patch ( "groundlight.client.requests" ) as mock_requests :
4037 mock_requests .post .return_value = _mock_response ()
41-
4238 result = gl .ask_vlm (media = np .zeros ((100 , 100 , 3 ), dtype = np .uint8 ), query = "Is there a fire?" )
4339
44- assert isinstance (result , VLMVerificationResult )
45- assert result .verdict == "YES"
46- assert result .confidence == pytest .approx (0.92 )
47- assert result .id == "vlmv_test123"
48- assert result .input_tokens == 400
49- assert result .total_cost_usd == pytest .approx (0.0015 )
40+ assert isinstance (result , VLMVerificationResult )
41+ assert result .verdict == "YES"
42+ assert result .confidence == pytest .approx (0.92 )
43+ assert result .id == "vlmv_test123"
44+ assert result .input_tokens == 400
45+ assert result .total_cost_usd == pytest .approx (0.0015 )
46+
5047
51- @patch ("groundlight.client.requests" )
52- def test_single_numpy_image_encoded_as_jpeg (self , mock_requests , gl ):
48+ def test_single_numpy_image_encoded_as_jpeg (gl : Groundlight ):
49+ """A numpy array is encoded to JPEG and sent as a single multipart 'media' part."""
50+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
5351 mock_requests .post .return_value = _mock_response ()
54- frame = np .zeros ((480 , 640 , 3 ), dtype = np .uint8 )
52+ gl . ask_vlm ( media = np .zeros ((480 , 640 , 3 ), dtype = np .uint8 ), query = "Is there a fire?" )
5553
56- gl .ask_vlm (media = frame , query = "Is there a fire?" )
54+ _ , kwargs = mock_requests .post .call_args
55+ files = kwargs ["files" ]
56+ assert len (files ) == 1
57+ assert files [0 ][0 ] == "media"
58+ _name , data , ctype = files [0 ][1 ]
59+ assert ctype == "image/jpeg"
60+ assert len (data ) > 0
5761
58- _ , kwargs = mock_requests .post .call_args
59- files = kwargs ["files" ]
60- assert len (files ) == 1
61- assert files [0 ][0 ] == "media"
62- name , data , ctype = files [0 ][1 ]
63- assert ctype == "image/jpeg"
64- assert len (data ) > 0 # bytes were produced
6562
66- @patch ("groundlight.client.requests" )
67- def test_dual_images_sends_two_parts (self , mock_requests , gl ):
63+ def test_dual_images_sends_two_parts (gl : Groundlight ):
64+ """Passing a list of two images sends two 'media' multipart parts."""
65+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
6866 mock_requests .post .return_value = _mock_response ()
69- frame = np .zeros ((480 , 640 , 3 ), dtype = np .uint8 )
70- roi = np .zeros ((120 , 120 , 3 ), dtype = np .uint8 )
67+ gl .ask_vlm (
68+ media = [np .zeros ((480 , 640 , 3 ), dtype = np .uint8 ), np .zeros ((120 , 120 , 3 ), dtype = np .uint8 )],
69+ query = "Is there a fire?" ,
70+ )
7171
72- gl .ask_vlm (media = [frame , roi ], query = "Is there a fire?" )
72+ _ , kwargs = mock_requests .post .call_args
73+ assert len (kwargs ["files" ]) == 2
7374
74- _ , kwargs = mock_requests .post .call_args
75- assert len (kwargs ["files" ]) == 2
7675
77- @patch ("groundlight.client.requests" )
78- def test_url_has_correct_path (self , mock_requests , gl ):
76+ def test_url_has_correct_path (gl : Groundlight ):
77+ """sanitize_endpoint_url strips the trailing slash, so we must insert '/' before
78+ the path — without it the URL would be '...device-apiv1/vlm-verifications'."""
79+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
7980 mock_requests .post .return_value = _mock_response ()
80-
8181 gl .ask_vlm (media = np .zeros ((100 , 100 , 3 ), dtype = np .uint8 ), query = "test" )
8282
83- args , _ = mock_requests .post .call_args
84- url = args [0 ]
85- # sanitize_endpoint_url strips the trailing slash, so we must insert "/" before
86- # the path — without it the URL would be "...device-apiv1/vlm-verifications".
87- assert url .endswith ("/v1/vlm-verifications" ), f"Bad URL: { url } "
88- assert "/device-api/v1/vlm-verifications" in url
83+ args , _ = mock_requests .post .call_args
84+ url = args [0 ]
85+ assert "/device-api/v1/vlm-verifications" in url
8986
90- @patch ("groundlight.client.requests" )
91- def test_query_and_model_id_sent_as_form_fields (self , mock_requests , gl ):
92- mock_requests .post .return_value = _mock_response (model_id = "nova-pro" )
9387
88+ def test_query_and_model_id_sent_as_form_fields (gl : Groundlight ):
89+ """query and model_id go in the multipart body, never in the URL query string."""
90+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
91+ mock_requests .post .return_value = _mock_response (model_id = "nova-pro" )
9492 gl .ask_vlm (media = np .zeros ((100 , 100 , 3 ), dtype = np .uint8 ), query = "Is there a fire?" , model_id = "nova-pro" )
9593
96- _ , kwargs = mock_requests .post .call_args
97- # Text fields go in the multipart body, never the URL query string.
98- assert kwargs ["data" ]["query" ] == "Is there a fire?"
99- assert kwargs ["data" ]["model_id" ] == "nova-pro"
100- assert "params" not in kwargs or not kwargs ["params" ]
94+ _ , kwargs = mock_requests .post .call_args
95+ assert kwargs ["data" ]["query" ] == "Is there a fire?"
96+ assert kwargs ["data" ]["model_id" ] == "nova-pro"
97+ assert "params" not in kwargs or not kwargs .get ("params" )
10198
102- @patch ("groundlight.client.requests" )
103- def test_no_model_id_omits_field (self , mock_requests , gl ):
104- mock_requests .post .return_value = _mock_response ()
10599
100+ def test_no_model_id_omits_field (gl : Groundlight ):
101+ """Omitting model_id leaves the field out entirely so the server uses its default."""
102+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
103+ mock_requests .post .return_value = _mock_response ()
106104 gl .ask_vlm (media = np .zeros ((100 , 100 , 3 ), dtype = np .uint8 ), query = "test" )
107105
108- _ , kwargs = mock_requests .post .call_args
109- assert "model_id" not in kwargs ["data" ]
110- assert kwargs ["data" ]["query" ] == "test"
106+ _ , kwargs = mock_requests .post .call_args
107+ assert "model_id" not in kwargs ["data" ]
111108
112- def test_more_than_eight_media_raises (self , gl ):
113- frame = np .zeros ((100 , 100 , 3 ), dtype = np .uint8 )
114- with pytest .raises (ValueError , match = "at most 8" ):
115- gl .ask_vlm (media = [frame ] * 9 , query = "test" )
116109
117- @patch ("groundlight.client.requests" )
118- def test_bytes_image_accepted (self , mock_requests , gl ):
110+ def test_more_than_eight_media_raises (gl : Groundlight ):
111+ """Supplying more than 8 media items raises ValueError before any network call."""
112+ with pytest .raises (ValueError , match = "at most 8" ):
113+ gl .ask_vlm (media = [np .zeros ((100 , 100 , 3 ), dtype = np .uint8 )] * 9 , query = "test" )
114+
115+
116+ def test_timeout_passed_to_requests (gl : Groundlight ):
117+ """The timeout parameter is forwarded to requests.post."""
118+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
119119 mock_requests .post .return_value = _mock_response ()
120- # A minimal valid JPEG header
121- jpeg_bytes = b"\xff \xd8 \xff \xe0 " + b"\x00 " * 100
122-
123- # Should not raise
124- try :
125- gl .ask_vlm (media = jpeg_bytes , query = "test" )
126- except Exception :
127- pass # parse_supported_image_types may reject invalid JPEG body; that's fine here
120+ gl .ask_vlm (media = np .zeros ((100 , 100 , 3 ), dtype = np .uint8 ), query = "test" , timeout = 5.0 )
121+
122+ _ , kwargs = mock_requests .post .call_args
123+ assert kwargs ["timeout" ] == pytest .approx (5.0 )
124+
125+
126+ def test_corrupted_image_bytes_raises_http_error (gl : Groundlight ):
127+ """Corrupted bytes are not validated client-side — the server rejects them with a
128+ 400, which raise_for_status() converts to requests.HTTPError."""
129+ error_resp = MagicMock ()
130+ error_resp .status_code = 400
131+ error_resp .raise_for_status .side_effect = Exception ("400 Bad Request" )
132+
133+ with mock .patch ("groundlight.client.requests" ) as mock_requests :
134+ mock_requests .post .return_value = error_resp
135+ with pytest .raises (Exception , match = "400" ):
136+ gl .ask_vlm (media = b"this-is-not-a-valid-image" , query = "test" )
0 commit comments