Files
glonlas-comfyUI-LMStudio-nodes/tests/test_behavioral_contracts.py
Gérald LONLASandClaude Opus 4.8 1ccce73904 test(contracts): add behavioral-contract tests that catch value-mutant regressions (#15)
25 new tests across client.py, routes.py, connect_node.py, and text_gen_node.py.
Each test targets a specific behavioral contract that 100% line+branch coverage
did not verify and that a realistic mutation would silently break:

- normalize_server_url: whitespace-only input, trailing-slash stripping,
  https acceptance
- coerce_seed: whitespace-only string returns None (not ValueError)
- resolve_request_seed: seed=0 passes through; large values not clipped
- build_responses_input_text: 'type' must be 'input_text' (not 'text')
- build_chat_messages: whitespace system prompt omitted; empty user included
- list_models: result is sorted AND deduplicated (both invariants checked)
- strip_think_content: surrounding text order preserved; block-only → ""
- _query_int: negative integers rejected; empty string uses default
- _models_handler: empty models → default_model="" not IndexError;
  error prefix is 'Failed to list models' not 'Connection test failed'
- _test_handler: model_count matches actual list length; distinct error prefix
- connect_node execute: empty model with test_connectivity=False still probes;
  no-probe status omits 'Found N model(s)'; probe status includes exact count
- _responses_kwargs: 'input' key present with user-role and correct prompt text;
  temperature/max_tokens forwarded from payload
- text_gen_node execute: fallback path status includes resolved seed;
  user_prompt=None raises clean ValueError

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:22:32 +08:00

546 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for behavioral contracts that 100% line/branch coverage leaves unverified.
Each test is annotated with the mutation it would catch (i.e. what regression
would slip through if the test didn't exist).
"""
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
import pytest
import client
from helpers.imports import import_repo_module
# ---------------------------------------------------------------------------
# client.py – normalize_server_url
# ---------------------------------------------------------------------------
def test_normalize_server_url_whitespace_only_raises() -> None:
"""
Whitespace-only input must raise 'must not be empty'.
A mutation that strips before the emptiness check would silently accept
whitespace and try to parse it, producing a confusing error or wrong URL.
"""
with pytest.raises(ValueError, match="must not be empty"):
client.normalize_server_url(" ")
def test_normalize_server_url_trailing_slash_no_v1() -> None:
"""
A URL with a trailing slash but no /v1 suffix must have the slash stripped.
If the strip was removed, to_openai_base_url would produce 'http://host//v1'.
"""
assert client.normalize_server_url("http://127.0.0.1:1234/") == "http://127.0.0.1:1234"
def test_normalize_server_url_https_accepted() -> None:
"""
https:// scheme must be accepted.
A mutation that only allows http:// would silently break TLS setups.
"""
assert client.normalize_server_url("https://example.com:5000") == "https://example.com:5000"
# ---------------------------------------------------------------------------
# client.py – coerce_seed: whitespace-only string
# ---------------------------------------------------------------------------
def test_coerce_seed_whitespace_only_string_returns_none() -> None:
"""
A whitespace-only seed string (e.g. " ") must return None (treated as
'no seed provided'), exactly like an empty string.
A mutation that compared `value == ""` instead of `not stripped` would
return None for "" but raise ValueError for " ", breaking nodes that
receive padding from UI widgets.
"""
assert client.coerce_seed(" ") is None
assert client.coerce_seed("\t") is None
# ---------------------------------------------------------------------------
# client.py – resolve_request_seed: seed=0 is valid (boundary)
# ---------------------------------------------------------------------------
def test_resolve_request_seed_zero_is_valid() -> None:
"""
seed=0 is explicitly >= 0 and not -1, so it must be returned as-is.
A mutation of the seed == -1 check to seed <= 0 would incorrectly
randomise seed=0 instead of honouring the deterministic value.
"""
assert client.resolve_request_seed(0) == 0
def test_resolve_request_seed_large_positive_passes_through() -> None:
"""
A large positive seed must pass through unchanged.
Ensures no accidental clipping or modulo is applied.
"""
big = 2**62
assert client.resolve_request_seed(big) == big
# ---------------------------------------------------------------------------
# client.py – build_responses_input_text: content structure
# ---------------------------------------------------------------------------
def test_build_responses_input_text_content_type_is_input_text() -> None:
"""
The 'type' field in the content entry MUST be 'input_text', not 'text'.
A mutation swapping the type string would break LMStudio's Responses API
but would not be caught by shape-only assertions.
"""
payload = client.build_responses_input_text("hello world")
entry = payload[0]["content"][0]
assert entry["type"] == "input_text"
assert entry["text"] == "hello world"
# Exactly one content entry; no extra fields.
assert len(payload[0]["content"]) == 1
# ---------------------------------------------------------------------------
# client.py – build_chat_messages: system prompt skipping edge
# ---------------------------------------------------------------------------
def test_build_chat_messages_whitespace_system_skipped() -> None:
"""
A system_prompt that is only whitespace must be omitted (same as empty),
so that only the user message appears.
A mutation removing the .strip() call would include a whitespace-only
system message, poisoning the model's context.
"""
messages = client.build_chat_messages(" \t ", "ask something")
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "ask something"
def test_build_chat_messages_empty_user_prompt_included() -> None:
"""
Even an empty user prompt must produce a user message (the slot must be
present). A mutation that skipped the user message when content is empty
would cause the API call to fail with a missing-message error.
"""
messages = client.build_chat_messages("System", "")
# system + user
assert len(messages) == 2
assert messages[1] == {"role": "user", "content": ""}
# ---------------------------------------------------------------------------
# client.py – list_models: ordering / dedup contract
# ---------------------------------------------------------------------------
def test_list_models_returns_sorted_unique_ids() -> None:
"""
list_models must return a sorted, deduplicated list.
A mutation removing sorted() would produce an arbitrary order, breaking
deterministic dropdown selection in the UI.
A mutation removing set() dedup would expose duplicate model entries.
"""
from types import SimpleNamespace as NS
model_data = [
NS(id="z-model"),
NS(id="a-model"),
NS(id="m-model"),
NS(id="a-model"), # duplicate
NS(id=""), # empty – filtered
]
fake_client = NS(models=NS(list=lambda: NS(data=model_data)))
result = client.list_models(fake_client)
assert result == ["a-model", "m-model", "z-model"]
# Idempotent: second call produces same order.
assert client.list_models(fake_client) == result
# ---------------------------------------------------------------------------
# client.py – strip_think_content: multiple blocks, ordering preserved
# ---------------------------------------------------------------------------
def test_strip_think_content_preserves_surrounding_text_order() -> None:
"""
Text before and after think blocks must appear in the correct order.
A mutation reversing the substitution or collapsing surrounding text
would reorder the visible output.
"""
result = client.strip_think_content("A <think>x</think> B <think>y</think> C")
assert result == "A B C"
def test_strip_think_content_only_think_block_returns_empty() -> None:
"""
A string consisting entirely of a think block must return "".
A mutation that stripped surrounding text instead of the block would
produce the block content rather than the empty string.
"""
assert client.strip_think_content("<think>only reasoning</think>") == ""
# ---------------------------------------------------------------------------
# routes.py – _query_int: negative integer rejected
# ---------------------------------------------------------------------------
def _routes_module():
return import_repo_module("routes", force_reload=True)
def test_query_int_negative_value_raises() -> None:
"""
Negative integers (e.g. -1, -5) must be rejected with '>= 1'.
The check is `value < 1`, which covers both 0 (already tested upstream)
and negative numbers. A mutation changing < 1 to == 0 would silently
accept -1 as a timeout, causing immediate connection failures.
"""
routes = _routes_module()
for raw in ["-1", "-100"]:
with pytest.raises(ValueError, match="must be >= 1"):
routes._query_int(
SimpleNamespace(query={"timeout_seconds": raw}),
"timeout_seconds",
15,
)
def test_query_int_empty_string_uses_default() -> None:
"""
An empty-string query value must fall back to the default, not raise.
A mutation that only checked `raw is None` (not `raw == ""`) would try
to parse "" and raise ValueError instead of returning the default.
"""
routes = _routes_module()
request = SimpleNamespace(query={"timeout_seconds": ""})
assert routes._query_int(request, "timeout_seconds", 15) == 15
# ---------------------------------------------------------------------------
# routes.py – _models_handler: empty models list produces correct default_model
# ---------------------------------------------------------------------------
def test_models_handler_empty_models_default_model_is_empty_string(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the server returns no models, default_model must be "" (empty string),
not raise an IndexError.
A mutation changing `models[0] if models else ""` to just `models[0]`
would crash on empty lists.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(routes, "get_server_models", lambda **kwargs: [])
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._models_handler(request))
payload = json.loads(response.text)
assert response.status == 200
assert payload["ok"] is True
assert payload["default_model"] == ""
assert payload["models"] == []
# ---------------------------------------------------------------------------
# routes.py – _test_handler: model_count matches actual list length
# ---------------------------------------------------------------------------
def test_test_handler_model_count_matches_models_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
model_count in the response must equal len(models).
The existing test only checks model_count==1; this one checks that the
count is derived from the list, not hardcoded.
A mutation replacing len(models) with 1 would pass the existing test
but fail here.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(routes, "get_server_models", lambda **kwargs: ["a", "b", "c"])
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._test_handler(request))
payload = json.loads(response.text)
assert payload["model_count"] == 3
assert payload["models"] == ["a", "b", "c"]
assert "3 model(s)" in payload["message"]
def test_test_handler_error_prefix_is_connection_test_failed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The 502 error message for _test_handler must use the prefix
'Connection test failed', NOT 'Failed to list models' (which is the
_models_handler prefix). A copy-paste mutation that shared the same
error string between the two handlers would confuse operator debugging.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(
routes,
"get_server_models",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("timeout")),
)
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._test_handler(request))
payload = json.loads(response.text)
assert response.status == 502
assert "Connection test failed" in payload["error"]
assert "Failed to list models" not in payload["error"]
def test_models_handler_error_prefix_is_failed_to_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Symmetric guard: the 502 error from _models_handler must use
'Failed to list models', not 'Connection test failed'.
"""
routes = _routes_module()
monkeypatch.setattr(routes, "normalize_server_url", lambda url: "http://x:1234")
monkeypatch.setattr(
routes,
"get_server_models",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
)
request = SimpleNamespace(query={"server_url": "http://x:1234"})
response = asyncio.run(routes._models_handler(request))
payload = json.loads(response.text)
assert response.status == 502
assert "Failed to list models" in payload["error"]
assert "Connection test failed" not in payload["error"]
# ---------------------------------------------------------------------------
# connect_node.py – auto-discover model when model="" and test_connectivity=False
# ---------------------------------------------------------------------------
def _connect_module():
return import_repo_module("connect_node", force_reload=True)
def test_execute_discovers_model_when_empty_and_connectivity_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When model="" (blank) and test_connectivity=False, the node must still
probe the server (because no model is selected) and use the first result.
should_probe_models is `test_connectivity OR model in {"", MODEL_PLACEHOLDER}`.
A mutation changing `or` to `and` would skip the probe and raise ValueError
('No model selected') instead of auto-selecting.
"""
connect_node = _connect_module()
monkeypatch.setattr(connect_node, "get_server_models", lambda **_: ["auto-model"])
output = connect_node.LMStudioConnect.execute(
server_url="http://127.0.0.1:1234",
api_token="-",
model="",
reasoning_enabled=False,
test_connectivity=False, # disabled, but model is blank → must still probe
max_tokens=128,
temperature=0.5,
timeout_seconds=10,
use_tooling_mcp=False,
)
payload = output[0]
assert payload.model == "auto-model"
def test_execute_status_no_probe_does_not_say_found_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When test_connectivity=False and a model is explicitly set, the status
message must say 'Connection prepared' and NOT say 'Found N model(s)'.
A mutation that always used the probe-status template would expose
incorrect model-count information.
"""
connect_node = _connect_module()
monkeypatch.setattr(
connect_node,
"get_server_models",
lambda **_: (_ for _ in ()).throw(AssertionError("must not probe")),
)
output = connect_node.LMStudioConnect.execute(
server_url="http://127.0.0.1:1234",
api_token="-",
model="chosen-model",
reasoning_enabled=False,
test_connectivity=False,
max_tokens=128,
temperature=0.5,
timeout_seconds=10,
use_tooling_mcp=False,
)
status = output[1]
assert "Connection prepared" in status
assert "Found" not in status
def test_execute_status_with_probe_includes_model_count(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
When the probe runs, the status must include 'Found N model(s)' with the
actual count.
A mutation hardcoding the count as 0 or 1 would pass the existing test
(which uses 2 models) but would be exposed by checking a different count.
"""
connect_node = _connect_module()
monkeypatch.setattr(
connect_node,
"get_server_models",
lambda **_: ["x", "y", "z"],
)
output = connect_node.LMStudioConnect.execute(
server_url="http://127.0.0.1:1234",
api_token="-",
model="x",
reasoning_enabled=False,
test_connectivity=True,
max_tokens=128,
temperature=0.5,
timeout_seconds=10,
use_tooling_mcp=False,
)
status = output[1]
assert "Found 3 model(s)" in status
# ---------------------------------------------------------------------------
# text_gen_node.py – _responses_kwargs: 'input' field is always present
# ---------------------------------------------------------------------------
def _text_module():
return import_repo_module("text_gen_node", force_reload=True)
def _connection_payload(**overrides):
models = import_repo_module("models", force_reload=True)
base = dict(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model="model-a",
reasoning_enabled=False,
max_tokens=111,
temperature=0.4,
timeout_seconds=30,
use_tooling_mcp=False,
)
base.update(overrides)
return models.LMStudioConnectionPayload(**base)
def test_responses_kwargs_input_field_is_present_and_has_user_role() -> None:
"""
The 'input' key in _responses_kwargs must be set to a list containing
a user-role message with the prompt text.
A mutation that removed or misspelled the 'input' key would send an
incomplete payload that LMStudio's Responses API would reject.
"""
text_gen_node = _text_module()
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=_connection_payload(),
system_prompt="",
user_prompt="describe the image",
seed=1,
)
assert "input" in kwargs
assert isinstance(kwargs["input"], list)
assert len(kwargs["input"]) == 1
assert kwargs["input"][0]["role"] == "user"
# The content must carry the user_prompt text.
content = kwargs["input"][0]["content"]
assert any(
(isinstance(entry, dict) and entry.get("text") == "describe the image")
for entry in content
), f"user_prompt not found in content: {content!r}"
def test_responses_kwargs_temperature_and_max_tokens_forwarded() -> None:
"""
temperature and max_output_tokens in _responses_kwargs must mirror the
connection payload values exactly.
A mutation swapping temperature and max_output_tokens, or using a
hardcoded default, would break generation quality silently.
"""
text_gen_node = _text_module()
conn = _connection_payload(temperature=1.5, max_tokens=512)
kwargs = text_gen_node.LMStudioTextGen._responses_kwargs(
connection=conn,
system_prompt="",
user_prompt="hello",
seed=7,
)
assert kwargs["temperature"] == 1.5
assert kwargs["max_output_tokens"] == 512
# ---------------------------------------------------------------------------
# text_gen_node.py – execute: seed appears in status for fallback path too
# ---------------------------------------------------------------------------
def test_execute_fallback_status_includes_seed(monkeypatch: pytest.MonkeyPatch) -> None:
"""
The status message must include the resolved seed even when falling back
to chat.completions. The existing fallback test checks for the fallback
reason, but NOT for the seed, so a mutation zeroing the seed in the
status template would go undetected.
"""
text_gen_node = _text_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="Answer"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(RuntimeError("responses off"))
),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 42)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
output = text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(),
system_prompt="sys",
user_prompt="hello",
seed=0,
)
assert output[0] == "Answer"
assert "(seed=42)" in output.ui.text
assert "via chat.completions" in output.ui.text
def test_execute_user_prompt_none_treated_as_empty_raises() -> None:
"""
user_prompt=None must be treated as empty and raise ValueError.
A mutation that skipped the `user_prompt = user_prompt or ""` coercion
would cause a NoneType AttributeError instead of a clean ValueError.
"""
text_gen_node = _text_module()
with pytest.raises(ValueError, match="user_prompt must not be empty"):
text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(),
system_prompt="sys",
user_prompt=None,
seed=1,
)