"""
Unit tests for validation module.
Tests all validation functions to ensure proper input checking and error handling.
"""
import pytest
from hue_mcp_server.validation import (
validate_light_id,
validate_group_id,
validate_scene_id,
validate_brightness,
validate_color_temperature,
validate_xy_coordinates,
)
from hue_mcp_server.hue_client import (
BRIGHTNESS_MIN_HUE_API,
BRIGHTNESS_MAX_HUE_API,
COLOR_TEMP_MIN_MIREDS,
COLOR_TEMP_MAX_MIREDS,
CIE_XY_MIN,
CIE_XY_MAX,
)
class TestLightIdValidation:
"""Tests for light ID validation."""
def test_valid_light_id(self):
"""Test that valid light IDs pass validation."""
validate_light_id("abc123")
validate_light_id("light-id-123")
validate_light_id("UUID-format-string")
validate_light_id("010d2ba7-9dc7-484f-bf6b-7ab1c8746ca8")
# Should not raise
def test_empty_light_id_raises_error(self):
"""Test that empty light IDs are rejected."""
with pytest.raises(ValueError, match="light_id is required"):
validate_light_id("")
with pytest.raises(ValueError, match="light_id is required"):
validate_light_id(" ")
def test_none_light_id_raises_error(self):
"""Test that None light ID is rejected."""
with pytest.raises(ValueError, match="light_id is required"):
validate_light_id(None)
def test_non_string_light_id_raises_error(self):
"""Test that non-string light IDs are rejected."""
with pytest.raises(ValueError, match="light_id must be a string"):
validate_light_id(123)
with pytest.raises(ValueError, match="light_id must be a string"):
validate_light_id(["abc"])
with pytest.raises(ValueError, match="light_id must be a string"):
validate_light_id({"id": "abc"})
class TestGroupIdValidation:
"""Tests for group ID validation."""
def test_valid_group_id(self):
"""Test that valid group IDs pass validation."""
validate_group_id("group-123")
validate_group_id("room-456")
validate_group_id("zone-789")
# Should not raise
def test_empty_group_id_raises_error(self):
"""Test that empty group IDs are rejected."""
with pytest.raises(ValueError, match="group_id is required"):
validate_group_id("")
with pytest.raises(ValueError, match="group_id is required"):
validate_group_id(None)
def test_non_string_group_id_raises_error(self):
"""Test that non-string group IDs are rejected."""
with pytest.raises(ValueError, match="group_id must be a string"):
validate_group_id(456)
class TestSceneIdValidation:
"""Tests for scene ID validation."""
def test_valid_scene_id(self):
"""Test that valid scene IDs pass validation."""
validate_scene_id("scene-123")
validate_scene_id("my-scene")
# Should not raise
def test_empty_scene_id_raises_error(self):
"""Test that empty scene IDs are rejected."""
with pytest.raises(ValueError, match="scene_id is required"):
validate_scene_id("")
with pytest.raises(ValueError, match="scene_id is required"):
validate_scene_id(None)
def test_non_string_scene_id_raises_error(self):
"""Test that non-string scene IDs are rejected."""
with pytest.raises(ValueError, match="scene_id must be a string"):
validate_scene_id(789)
class TestBrightnessValidation:
"""Tests for brightness validation."""
@pytest.mark.parametrize(
"brightness",
[
BRIGHTNESS_MIN_HUE_API, # 0
1,
50,
127,
200,
253,
BRIGHTNESS_MAX_HUE_API, # 254
],
)
def test_valid_brightness_values(self, brightness):
"""Test that valid brightness values pass validation."""
validate_brightness(brightness)
# Should not raise
@pytest.mark.parametrize("brightness", [-1, -100, 255, 256, 1000, -999])
def test_brightness_out_of_range_raises_error(self, brightness):
"""Test that out-of-range brightness values are rejected."""
with pytest.raises(ValueError, match="brightness must be between"):
validate_brightness(brightness)
@pytest.mark.parametrize("brightness", ["100", "abc", None, [], {}, [127]])
def test_brightness_wrong_type_raises_error(self, brightness):
"""Test that non-numeric brightness values are rejected."""
with pytest.raises(ValueError, match="brightness must be a number"):
validate_brightness(brightness)
def test_brightness_float_accepted(self):
"""Test that float brightness values are accepted."""
validate_brightness(127.5)
validate_brightness(0.0)
validate_brightness(254.0)
validate_brightness(100.5)
# Should not raise
def test_brightness_error_message_includes_type(self):
"""Test that error message includes the actual type received."""
with pytest.raises(ValueError, match="got str"):
validate_brightness("invalid")
with pytest.raises(ValueError, match="got list"):
validate_brightness([100])
class TestColorTemperatureValidation:
"""Tests for color temperature validation."""
@pytest.mark.parametrize(
"color_temp",
[
COLOR_TEMP_MIN_MIREDS, # 153
154,
200,
300,
400,
499,
COLOR_TEMP_MAX_MIREDS, # 500
],
)
def test_valid_color_temp_values(self, color_temp):
"""Test that valid color temperature values pass validation."""
validate_color_temperature(color_temp)
# Should not raise
@pytest.mark.parametrize("color_temp", [152, 150, 100, 0, 501, 502, 1000])
def test_color_temp_out_of_range_raises_error(self, color_temp):
"""Test that out-of-range color temp values are rejected."""
with pytest.raises(ValueError, match="color_temp must be between"):
validate_color_temperature(color_temp)
def test_color_temp_wrong_type_raises_error(self):
"""Test that non-numeric color temp values are rejected."""
with pytest.raises(ValueError, match="color_temp must be a number"):
validate_color_temperature("300")
with pytest.raises(ValueError, match="got NoneType"):
validate_color_temperature(None)
def test_color_temp_float_accepted(self):
"""Test that float color temp values are accepted."""
validate_color_temperature(300.0)
validate_color_temperature(153.5)
# Should not raise
def test_color_temp_boundary_values(self):
"""Test exact boundary values for color temperature."""
# Should pass
validate_color_temperature(COLOR_TEMP_MIN_MIREDS)
validate_color_temperature(COLOR_TEMP_MAX_MIREDS)
# Should fail
with pytest.raises(ValueError):
validate_color_temperature(COLOR_TEMP_MIN_MIREDS - 1)
with pytest.raises(ValueError):
validate_color_temperature(COLOR_TEMP_MAX_MIREDS + 1)
class TestXYCoordinatesValidation:
"""Tests for CIE xy coordinates validation."""
@pytest.mark.parametrize(
"xy",
[
[0.0, 0.0],
[1.0, 1.0],
[0.5, 0.5],
[0.3127, 0.3290], # D65 white point
[0.167, 0.04], # Blue
[0.7, 0.299], # Red
[0.409, 0.518], # Green
],
)
def test_valid_xy_coordinates(self, xy):
"""Test that valid xy coordinates pass validation."""
validate_xy_coordinates(xy)
# Should not raise
def test_xy_not_list_raises_error(self):
"""Test that non-list xy values are rejected."""
with pytest.raises(ValueError, match="xy must be a list"):
validate_xy_coordinates((0.5, 0.5))
with pytest.raises(ValueError, match="xy must be a list"):
validate_xy_coordinates("0.5,0.5")
with pytest.raises(ValueError, match="got dict"):
validate_xy_coordinates({"x": 0.5, "y": 0.5})
def test_xy_wrong_length_raises_error(self):
"""Test that xy with wrong number of values is rejected."""
with pytest.raises(ValueError, match="xy must be a list of two values"):
validate_xy_coordinates([0.5])
with pytest.raises(ValueError, match="xy must be a list of two values"):
validate_xy_coordinates([0.5, 0.5, 0.5])
with pytest.raises(ValueError, match="xy must be a list of two values"):
validate_xy_coordinates([])
@pytest.mark.parametrize(
"xy",
[
[-0.1, 0.5], # x too low
[0.5, -0.1], # y too low
[1.1, 0.5], # x too high
[0.5, 1.1], # y too high
[-1.0, 0.5], # x way too low
[0.5, 2.0], # y way too high
],
)
def test_xy_out_of_range_raises_error(self, xy):
"""Test that out-of-range xy values are rejected."""
with pytest.raises(ValueError, match="must be between"):
validate_xy_coordinates(xy)
def test_xy_non_numeric_values_raise_error(self):
"""Test that non-numeric values in xy are rejected."""
with pytest.raises(ValueError, match="must be a number"):
validate_xy_coordinates(["0.5", "0.5"])
with pytest.raises(ValueError, match="must be a number"):
validate_xy_coordinates([None, 0.5])
with pytest.raises(ValueError, match="must be a number"):
validate_xy_coordinates([0.5, None])
def test_xy_boundary_values(self):
"""Test exact boundary values for xy coordinates."""
# Should pass
validate_xy_coordinates([CIE_XY_MIN, CIE_XY_MIN])
validate_xy_coordinates([CIE_XY_MAX, CIE_XY_MAX])
validate_xy_coordinates([CIE_XY_MIN, CIE_XY_MAX])
validate_xy_coordinates([CIE_XY_MAX, CIE_XY_MIN])
# Should fail
with pytest.raises(ValueError):
validate_xy_coordinates([CIE_XY_MIN - 0.001, 0.5])
with pytest.raises(ValueError):
validate_xy_coordinates([0.5, CIE_XY_MAX + 0.001])
def test_xy_error_message_specifies_coordinate(self):
"""Test that error messages specify which coordinate (x or y) is invalid."""
with pytest.raises(ValueError, match=r"xy\[0\] \(x\)"):
validate_xy_coordinates(["invalid", 0.5])
with pytest.raises(ValueError, match=r"xy\[1\] \(y\)"):
validate_xy_coordinates([0.5, "invalid"])
class TestValidationIntegration:
"""Integration tests for validation functions."""
def test_all_validators_raise_value_error(self):
"""Test that all validators raise ValueError for invalid input."""
validators = [
(validate_light_id, [None]),
(validate_group_id, [None]),
(validate_scene_id, [None]),
(validate_brightness, [-1]),
(validate_color_temperature, [100]),
(validate_xy_coordinates, [[2.0, 0.5]]),
]
for validator, args in validators:
with pytest.raises(ValueError):
validator(*args)
def test_validators_accept_valid_inputs(self):
"""Test that all validators accept valid inputs without raising."""
validate_light_id("light-123")
validate_group_id("group-456")
validate_scene_id("scene-789")
validate_brightness(127)
validate_color_temperature(300)
validate_xy_coordinates([0.5, 0.5])
# Should not raise
def test_validators_provide_descriptive_errors(self):
"""Test that validators provide clear, descriptive error messages."""
try:
validate_brightness("not-a-number")
except ValueError as e:
assert "brightness" in str(e).lower()
assert "number" in str(e).lower()
assert "str" in str(e).lower()
try:
validate_xy_coordinates([0.5, 1.5])
except ValueError as e:
assert "xy" in str(e).lower()
assert "between" in str(e).lower()