@@ -0,0 +1,312 @@
"""
Tests targeting the remaining uncovered branches in client.py and image_to_text_node.py.
Coverage gaps addressed (as reported by pytest-cov on the main branch):
client.py : lines 117-118, 142, 207, 222->228, 224->223, 226->223, 234->232, 291->293
image_to_text_node: lines 112->114, 157, 181, 183->189
"""
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
import client
from helpers . fakes import FakeTensor , make_chat_completion , ns
from helpers . imports import import_repo_module
# ---------------------------------------------------------------------------
# client.py – coerce_seed: .item() call raises, falls back to plain value
# ---------------------------------------------------------------------------
class _BadItem :
""" Numpy-scalar-like object whose .item() always raises. """
def item ( self ) :
raise RuntimeError ( " item() failed " )
def test_coerce_seed_item_raises_falls_back_to_unsupported_type ( ) - > None :
"""
When value has .item() but the call raises, coerce_seed catches the exception
(lines 117-118) and falls through to the type-check below. Since _BadItem is
not int/float/str/bytes, it must then hit the final raise (line 142).
"""
with pytest . raises ( ValueError , match = " Unsupported seed type " ) :
client . coerce_seed ( _BadItem ( ) )
def test_coerce_seed_unsupported_type_raises ( ) - > None :
"""
A plain object with no recognised numeric/string interface must raise the
' Unsupported seed type ' error (line 142).
"""
with pytest . raises ( ValueError , match = " Unsupported seed type " ) :
client . coerce_seed ( object ( ) )
with pytest . raises ( ValueError , match = " Unsupported seed type " ) :
client . coerce_seed ( { " a " : 1 } )
# ---------------------------------------------------------------------------
# client.py – extract_chat_completion_text: content=[] returns ""
# ---------------------------------------------------------------------------
def test_extract_chat_completion_text_empty_list_content ( ) - > None :
"""
When the message.content is an empty list the function should return " " .
This exercises the branch at line 207 where content is a list but parts
ends up empty so " \n " .join returns " " .
"""
completion = make_chat_completion ( [ ] )
assert client . extract_chat_completion_text ( completion ) == " "
def test_extract_chat_completion_text_list_with_no_usable_text ( ) - > None :
"""
List content items where getattr(part, ' text ' , None) is None and the item
is also not a dict – they are silently skipped, exercising the branches
at lines 222->228 / 224->223 / 226->223.
"""
content = [
ns ( other = " no-text " ) , # has no .text, not a dict – skipped
ns ( text = None ) , # text attr is None – skipped
{ " other " : " ignored " } , # dict without 'text' key – skipped
ns ( text = " kept " ) , # text attr is str – included
]
completion = make_chat_completion ( content )
assert client . extract_chat_completion_text ( completion ) == " kept "
def test_extract_chat_completion_text_non_list_non_str_content ( ) - > None :
"""
When message.content is neither a str nor a list (e.g. an int or None),
the function should return " " via the final branch at line 207.
"""
assert client . extract_chat_completion_text ( make_chat_completion ( 42 ) ) == " "
assert client . extract_chat_completion_text ( make_chat_completion ( None ) ) == " "
# ---------------------------------------------------------------------------
# client.py – extract_responses_text: output list with no content at all
# ---------------------------------------------------------------------------
def test_extract_responses_text_output_list_empty_items ( ) - > None :
"""
output is a list but its items carry neither usable ' content ' lists nor
a ' text ' attribute, so parts stays empty and the function returns " " .
This exercises the branch at line 234->232.
"""
response = ns ( output_text = " " , output = [ ns ( content = None ) , ns ( ) ] )
assert client . extract_responses_text ( response ) == " "
def test_extract_responses_text_output_none ( ) - > None :
""" output attribute is None – falls through to return " " at the end. """
assert client . extract_responses_text ( ns ( output_text = " " , output = None ) ) == " "
# ---------------------------------------------------------------------------
# client.py – build_chat_messages_with_image: no system prompt (line 291->293)
# ---------------------------------------------------------------------------
def test_build_chat_messages_with_image_no_system_prompt ( ) - > None :
"""
When system_prompt is blank/empty the system message must be omitted so
only the user message is present. This covers branch 291->293.
"""
messages = client . build_chat_messages_with_image (
system_prompt = " " ,
user_prompt = " describe it " ,
image_data_url = " data:image/png;base64,abc== " ,
)
assert len ( messages ) == 1
assert messages [ 0 ] [ " role " ] == " user "
user_content = messages [ 0 ] [ " content " ]
assert user_content [ 0 ] == { " type " : " text " , " text " : " describe it " }
assert user_content [ 1 ] [ " type " ] == " image_url "
# None system_prompt is treated the same way.
messages_none = client . build_chat_messages_with_image (
system_prompt = None ,
user_prompt = " hello " ,
image_data_url = " data:image/png;base64,xyz== " ,
)
assert len ( messages_none ) == 1
# ---------------------------------------------------------------------------
# image_to_text_node.py – _responses_kwargs: empty system_prompt (line 112->114)
# ---------------------------------------------------------------------------
def _image_module ( ) :
return import_repo_module ( " image_to_text_node " , force_reload = True )
def _connection_payload ( * , reasoning_enabled : bool = False , use_tooling_mcp : bool = False ) :
models = import_repo_module ( " models " , force_reload = True )
return models . LMStudioConnectionPayload (
server_url = " http://127.0.0.1:1234 " ,
base_url = " http://127.0.0.1:1234/v1 " ,
api_key = " token " ,
model = " vision-model " ,
reasoning_enabled = reasoning_enabled ,
max_tokens = 128 ,
temperature = 0.3 ,
timeout_seconds = 30 ,
use_tooling_mcp = use_tooling_mcp ,
)
def _single_image ( ) - > FakeTensor :
return FakeTensor ( np . array ( [ [ [ 1.0 , 0.0 , 0.0 ] ] ] , dtype = np . float32 ) )
def _batch_image ( ) - > FakeTensor :
return FakeTensor (
np . array (
[
[ [ [ 1.0 , 0.0 , 0.0 ] ] ] ,
[ [ [ 0.0 , 0.0 , 1.0 ] ] ] ,
] ,
dtype = np . float32 ,
)
)
def test_responses_kwargs_no_system_prompt_omits_instructions ( ) - > None :
"""
When system_prompt is blank the ' instructions ' key must NOT appear in
the returned kwargs. This covers the branch at line 112->114.
"""
image_node = _image_module ( )
kwargs = image_node . LMStudioImageToText . _responses_kwargs (
connection = _connection_payload ( ) ,
image = _single_image ( ) ,
system_prompt = " " ,
user_prompt = " describe " ,
seed = 1 ,
)
assert " instructions " not in kwargs
kwargs_whitespace = image_node . LMStudioImageToText . _responses_kwargs (
connection = _connection_payload ( ) ,
image = _single_image ( ) ,
system_prompt = " " ,
user_prompt = " describe " ,
seed = 1 ,
)
assert " instructions " not in kwargs_whitespace
# ---------------------------------------------------------------------------
# image_to_text_node.py – execute: responses returns empty text (line 157)
# ---------------------------------------------------------------------------
def test_execute_responses_empty_text_falls_back_to_chat (
monkeypatch : pytest . MonkeyPatch ,
) - > None :
"""
When the responses endpoint returns an empty string the node must raise
a ValueError internally and fall back to chat.completions.
This covers line 157 ( ' raise ValueError( " responses endpoint returned no text output " ) ' ).
"""
image_node = _image_module ( )
completion = SimpleNamespace (
choices = [ SimpleNamespace ( message = SimpleNamespace ( content = " chat fallback text " ) ) ]
)
fake_client = SimpleNamespace (
responses = SimpleNamespace ( create = lambda * * _ : SimpleNamespace ( output_text = " " ) ) ,
chat = SimpleNamespace ( completions = SimpleNamespace ( create = lambda * * _ : completion ) ) ,
)
monkeypatch . setattr ( image_node , " resolve_request_seed " , lambda _ : 5 )
monkeypatch . setattr ( image_node , " create_openai_client " , lambda * * _ : fake_client )
output = image_node . LMStudioImageToText . execute (
connection = _connection_payload ( ) ,
image = _single_image ( ) ,
system_prompt = " sys " ,
user_prompt = " describe " ,
seed = - 1 ,
)
assert output [ 0 ] == " chat fallback text "
assert " via chat.completions " in output . ui . text
assert " responses endpoint returned no text output " in output . ui . text
# ---------------------------------------------------------------------------
# image_to_text_node.py – execute: chat fallback also empty (line 181)
# ---------------------------------------------------------------------------
def test_execute_both_endpoints_empty_raises (
monkeypatch : pytest . MonkeyPatch ,
) - > None :
"""
When both the responses endpoint and the chat.completions fallback return
empty text, a ValueError must propagate to the caller.
This covers line 181 ( ' raise ValueError( " chat.completions fallback returned no text output " ) ' ).
"""
image_node = _image_module ( )
empty_completion = SimpleNamespace (
choices = [ SimpleNamespace ( message = SimpleNamespace ( content = " " ) ) ]
)
fake_client = SimpleNamespace (
responses = SimpleNamespace ( create = lambda * * _ : SimpleNamespace ( output_text = " " ) ) ,
chat = SimpleNamespace ( completions = SimpleNamespace ( create = lambda * * _ : empty_completion ) ) ,
)
monkeypatch . setattr ( image_node , " resolve_request_seed " , lambda _ : 3 )
monkeypatch . setattr ( image_node , " create_openai_client " , lambda * * _ : fake_client )
with pytest . raises ( ValueError , match = " chat.completions fallback returned no text output " ) :
image_node . LMStudioImageToText . execute (
connection = _connection_payload ( ) ,
image = _single_image ( ) ,
system_prompt = " sys " ,
user_prompt = " describe " ,
seed = 0 ,
)
# ---------------------------------------------------------------------------
# image_to_text_node.py – execute: chat fallback single image, no dropped-frames note (line 183->189)
# ---------------------------------------------------------------------------
def test_execute_chat_fallback_single_image_no_dropped_frames_note (
monkeypatch : pytest . MonkeyPatch ,
) - > None :
"""
When the responses endpoint fails and the image is a *single* frame (not a
batch), the fallback status message must NOT mention dropped frames.
This covers the branch at line 183->189 where batch_size == 1.
"""
image_node = _image_module ( )
def raise_responses ( * * kwargs ) :
raise RuntimeError ( " responses unavailable " )
completion = SimpleNamespace (
choices = [ SimpleNamespace ( message = SimpleNamespace ( content = " single fallback " ) ) ]
)
fake_client = SimpleNamespace (
responses = SimpleNamespace ( create = raise_responses ) ,
chat = SimpleNamespace ( completions = SimpleNamespace ( create = lambda * * _ : completion ) ) ,
)
monkeypatch . setattr ( image_node , " resolve_request_seed " , lambda _ : 7 )
monkeypatch . setattr ( image_node , " create_openai_client " , lambda * * _ : fake_client )
output = image_node . LMStudioImageToText . execute (
connection = _connection_payload ( ) ,
image = _single_image ( ) ,
system_prompt = " sys " ,
user_prompt = " describe " ,
seed = 0 ,
)
assert output [ 0 ] == " single fallback "
assert " via chat.completions " in output . ui . text
assert " dropped " not in output . ui . text