Files
2026-07-13 12:49:17 +08:00

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"