592 lines
18 KiB
Python
592 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from services.tools.manage_camera import (
|
|
manage_camera,
|
|
ALL_ACTIONS,
|
|
SETUP_ACTIONS,
|
|
CREATION_ACTIONS,
|
|
CONFIGURATION_ACTIONS,
|
|
EXTENSION_ACTIONS,
|
|
CONTROL_ACTIONS,
|
|
CAPTURE_ACTIONS,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def mock_unity(monkeypatch):
|
|
"""Patch Unity transport layer and return captured call dict."""
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_send(send_fn, unity_instance, tool_name, params):
|
|
captured["unity_instance"] = unity_instance
|
|
captured["tool_name"] = tool_name
|
|
captured["params"] = params
|
|
return {"success": True, "message": "ok"}
|
|
|
|
monkeypatch.setattr(
|
|
"services.tools.manage_camera.get_unity_instance_from_context",
|
|
AsyncMock(return_value="unity-instance-1"),
|
|
)
|
|
monkeypatch.setattr(
|
|
"services.tools.manage_camera.send_with_unity_instance",
|
|
fake_send,
|
|
)
|
|
return captured
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action list completeness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_all_actions_is_union_of_sub_lists():
|
|
expected = set(
|
|
SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS
|
|
+ EXTENSION_ACTIONS + CONTROL_ACTIONS + CAPTURE_ACTIONS
|
|
)
|
|
assert set(ALL_ACTIONS) == expected
|
|
|
|
|
|
def test_no_duplicate_actions():
|
|
assert len(ALL_ACTIONS) == len(set(ALL_ACTIONS))
|
|
|
|
|
|
def test_all_actions_count():
|
|
assert len(ALL_ACTIONS) == 18
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invalid / missing action
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_unknown_action_returns_error(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="nonexistent_action")
|
|
)
|
|
assert result["success"] is False
|
|
assert "Unknown action" in result["message"]
|
|
assert "tool_name" not in mock_unity
|
|
|
|
|
|
def test_empty_action_returns_error(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="")
|
|
)
|
|
assert result["success"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ping_sends_correct_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="ping")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["tool_name"] == "manage_camera"
|
|
assert mock_unity["params"]["action"] == "ping"
|
|
|
|
|
|
def test_ensure_brain_sends_correct_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="ensure_brain",
|
|
properties={"defaultBlendStyle": "EaseInOut", "defaultBlendDuration": 2.0},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "ensure_brain"
|
|
assert mock_unity["params"]["properties"]["defaultBlendStyle"] == "EaseInOut"
|
|
|
|
|
|
def test_get_brain_status_sends_correct_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="get_brain_status")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "get_brain_status"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Camera creation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_create_camera_with_preset(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="create_camera",
|
|
properties={
|
|
"name": "CM ThirdPerson",
|
|
"preset": "third_person",
|
|
"follow": "Player",
|
|
"lookAt": "Player",
|
|
"priority": 10,
|
|
},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "create_camera"
|
|
props = mock_unity["params"]["properties"]
|
|
assert props["preset"] == "third_person"
|
|
assert props["follow"] == "Player"
|
|
assert props["priority"] == 10
|
|
|
|
|
|
def test_create_camera_minimal(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="create_camera")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "create_camera"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_set_target_sends_follow_and_lookat(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_target",
|
|
target="CM Camera",
|
|
properties={"follow": "Player", "lookAt": "Player"},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["target"] == "CM Camera"
|
|
assert mock_unity["params"]["properties"]["follow"] == "Player"
|
|
|
|
|
|
def test_set_lens_sends_properties(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_lens",
|
|
target="CM Camera",
|
|
properties={"fieldOfView": 40.0, "nearClipPlane": 0.1},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["fieldOfView"] == 40.0
|
|
|
|
|
|
def test_set_priority_sends_value(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_priority",
|
|
target="CM Camera",
|
|
properties={"priority": 20},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["priority"] == 20
|
|
|
|
|
|
def test_set_body_with_type_swap(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_body",
|
|
target="CM Camera",
|
|
properties={"bodyType": "CinemachineFollow", "cameraDistance": 5.0},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["bodyType"] == "CinemachineFollow"
|
|
assert mock_unity["params"]["properties"]["cameraDistance"] == 5.0
|
|
|
|
|
|
def test_set_aim_with_type_swap(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_aim",
|
|
target="CM Camera",
|
|
properties={"aimType": "CinemachineHardLookAt"},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["aimType"] == "CinemachineHardLookAt"
|
|
|
|
|
|
def test_set_noise_sends_amplitude_frequency(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_noise",
|
|
target="CM Camera",
|
|
properties={"amplitudeGain": 0.5, "frequencyGain": 1.0},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["amplitudeGain"] == 0.5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extension actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_add_extension_sends_type(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="add_extension",
|
|
target="CM Camera",
|
|
properties={"extensionType": "CinemachineDeoccluder"},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["extensionType"] == "CinemachineDeoccluder"
|
|
|
|
|
|
def test_remove_extension_sends_type(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="remove_extension",
|
|
target="CM Camera",
|
|
properties={"extensionType": "CinemachineDeoccluder"},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["extensionType"] == "CinemachineDeoccluder"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Control actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_set_blend_sends_style_and_duration(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_blend",
|
|
properties={"style": "EaseInOut", "duration": 2.0},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"]["style"] == "EaseInOut"
|
|
assert mock_unity["params"]["properties"]["duration"] == 2.0
|
|
|
|
|
|
def test_force_camera_sends_target(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="force_camera",
|
|
target="CM Cinematic",
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["target"] == "CM Cinematic"
|
|
|
|
|
|
def test_release_override_sends_no_extra_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="release_override")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "release_override"
|
|
assert "target" not in mock_unity["params"]
|
|
assert "properties" not in mock_unity["params"]
|
|
|
|
|
|
def test_list_cameras_sends_no_extra_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="list_cameras")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "list_cameras"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Capture actions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_screenshot_sends_basic_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
camera="Main Camera",
|
|
include_image=True,
|
|
max_resolution=512,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "screenshot"
|
|
assert mock_unity["params"]["camera"] == "Main Camera"
|
|
assert mock_unity["params"]["includeImage"] is True
|
|
assert mock_unity["params"]["maxResolution"] == 512
|
|
|
|
|
|
def test_screenshot_with_filename(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
screenshot_file_name="test-capture",
|
|
screenshot_super_size=2,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["fileName"] == "test-capture"
|
|
assert mock_unity["params"]["superSize"] == 2
|
|
|
|
|
|
def test_screenshot_batch_surround(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
batch="surround",
|
|
view_target="Player",
|
|
include_image=True,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["batch"] == "surround"
|
|
assert mock_unity["params"]["viewTarget"] == "Player"
|
|
|
|
|
|
def test_screenshot_batch_orbit(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
batch="orbit",
|
|
orbit_angles=6,
|
|
orbit_elevations=[0.0, 30.0],
|
|
orbit_distance=10.0,
|
|
orbit_fov=50.0,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["batch"] == "orbit"
|
|
assert mock_unity["params"]["orbitAngles"] == 6
|
|
assert mock_unity["params"]["orbitElevations"] == [0.0, 30.0]
|
|
assert mock_unity["params"]["orbitDistance"] == 10.0
|
|
assert mock_unity["params"]["orbitFov"] == 50.0
|
|
|
|
|
|
def test_screenshot_positioned(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
view_position=[5.0, 3.0, -10.0],
|
|
view_target="Player",
|
|
include_image=True,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["viewPosition"] == [5.0, 3.0, -10.0]
|
|
assert mock_unity["params"]["viewTarget"] == "Player"
|
|
|
|
|
|
def test_screenshot_scene_view_capture_params(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
capture_source="scene_view",
|
|
view_target="Canvas",
|
|
include_image=True,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["captureSource"] == "scene_view"
|
|
assert mock_unity["params"]["viewTarget"] == "Canvas"
|
|
assert mock_unity["params"]["includeImage"] is True
|
|
|
|
|
|
def test_screenshot_invalid_capture_source(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
capture_source="editor_view",
|
|
)
|
|
)
|
|
assert result["success"] is False
|
|
assert "capture_source must be either" in result["message"]
|
|
assert "params" not in mock_unity
|
|
|
|
|
|
def test_screenshot_scene_view_rejects_batch_in_python(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
capture_source="scene_view",
|
|
batch="surround",
|
|
)
|
|
)
|
|
assert result["success"] is False
|
|
assert "does not support batch modes" in result["message"]
|
|
assert "params" not in mock_unity
|
|
|
|
|
|
def test_screenshot_view_target_works_without_capture_source(mock_unity):
|
|
"""view_target should work for both game_view and scene_view (no rejection)."""
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
view_target="Player",
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["viewTarget"] == "Player"
|
|
|
|
|
|
def test_screenshot_multiview_sends_action(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="screenshot_multiview")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "screenshot_multiview"
|
|
|
|
|
|
def test_screenshot_params_not_sent_for_non_capture_actions(mock_unity):
|
|
"""Screenshot params should be ignored for non-capture actions."""
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="ping",
|
|
camera="Main Camera",
|
|
include_image=True,
|
|
max_resolution=512,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert "camera" not in mock_unity["params"]
|
|
assert "includeImage" not in mock_unity["params"]
|
|
|
|
|
|
def test_screenshot_invalid_max_resolution(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
max_resolution=0,
|
|
)
|
|
)
|
|
assert result["success"] is False
|
|
assert "max_resolution" in result["message"]
|
|
|
|
|
|
def test_screenshot_invalid_orbit_elevations(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="screenshot",
|
|
batch="orbit",
|
|
orbit_elevations="not-json",
|
|
)
|
|
)
|
|
assert result["success"] is False
|
|
assert "orbit_elevations" in result["message"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parameter handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_search_method_passed_through(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="set_target",
|
|
target="12345",
|
|
search_method="by_id",
|
|
properties={"follow": "Player"},
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["searchMethod"] == "by_id"
|
|
|
|
|
|
def test_none_params_omitted(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="ping",
|
|
target=None,
|
|
search_method=None,
|
|
properties=None,
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert "target" not in mock_unity["params"]
|
|
assert "searchMethod" not in mock_unity["params"]
|
|
assert "properties" not in mock_unity["params"]
|
|
|
|
|
|
def test_string_properties_passed_through(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(
|
|
SimpleNamespace(),
|
|
action="create_camera",
|
|
properties='{"name": "TestCam", "preset": "follow"}',
|
|
)
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["properties"] == '{"name": "TestCam", "preset": "follow"}'
|
|
|
|
|
|
def test_non_dict_response_wrapped(monkeypatch):
|
|
"""When Unity returns a non-dict, it should be wrapped."""
|
|
monkeypatch.setattr(
|
|
"services.tools.manage_camera.get_unity_instance_from_context",
|
|
AsyncMock(return_value="unity-1"),
|
|
)
|
|
|
|
async def fake_send(send_fn, unity_instance, tool_name, params):
|
|
return "unexpected string response"
|
|
|
|
monkeypatch.setattr(
|
|
"services.tools.manage_camera.send_with_unity_instance",
|
|
fake_send,
|
|
)
|
|
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="ping")
|
|
)
|
|
assert result["success"] is False
|
|
assert "unexpected string response" in result["message"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Case insensitivity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_action_case_insensitive(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="Create_Camera")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "create_camera"
|
|
|
|
|
|
def test_action_uppercase(mock_unity):
|
|
result = asyncio.run(
|
|
manage_camera(SimpleNamespace(), action="PING")
|
|
)
|
|
assert result["success"] is True
|
|
assert mock_unity["params"]["action"] == "ping"
|