from __future__ import annotations import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from services.tools.unity_docs import ( unity_docs, ALL_ACTIONS, _extract_version, _build_doc_url, _build_property_url, _parse_unity_doc_html, _parse_manual_html, ) # --------------------------------------------------------------------------- # Sample HTML for parser tests # --------------------------------------------------------------------------- SAMPLE_DOC_HTML = """\
public static bool Raycast(Vector3 origin, Vector3 direction)
Casts a ray against all colliders in the Scene.
| origin | The starting point of the ray in world coordinates. |
| direction | The direction of the ray. |
bool True when the ray intersects any collider.
void Update() {
if (Physics.Raycast(transform.position, transform.forward, 100))
Debug.Log("Hit something");
}
| origin | The starting point of the ray in world coordinates. |
| direction | The direction of the ray. |
bool Returns true if the ray intersects with a Collider.
Casts a ray against all colliders in the Scene.
void Update() {
Physics.Raycast(transform.position, Vector3.forward, 10f);
}
Unity calls event functions in a specific order.
Awake is called first, then OnEnable, then Start.
void Awake() {
Debug.Log("Awake");
}
"""
# ---------------------------------------------------------------------------
# Manual HTML parsing (pure)
# ---------------------------------------------------------------------------
def test_parse_manual_title():
result = _parse_manual_html(SAMPLE_MANUAL_HTML)
assert result["title"] == "Execution Order"
def test_parse_manual_sections():
result = _parse_manual_html(SAMPLE_MANUAL_HTML)
assert len(result["sections"]) == 2
assert result["sections"][0]["heading"] == "Overview"
assert "event functions" in result["sections"][0]["content"]
assert result["sections"][1]["heading"] == "Initialization"
assert "Awake is called first" in result["sections"][1]["content"]
def test_parse_manual_code_examples():
result = _parse_manual_html(SAMPLE_MANUAL_HTML)
assert len(result["code_examples"]) == 1
assert "Debug.Log" in result["code_examples"][0]
def test_parse_manual_empty():
result = _parse_manual_html("")
assert result["title"] == ""
assert result["sections"] == []
assert result["code_examples"] == []
# ---------------------------------------------------------------------------
# get_manual action tests (mock _fetch_url)
# ---------------------------------------------------------------------------
def test_get_manual_success():
async def mock_fetch(url):
return (200, SAMPLE_MANUAL_HTML)
with patch("services.tools.unity_docs._fetch_url", side_effect=mock_fetch):
result = asyncio.run(
unity_docs(SimpleNamespace(), action="get_manual", slug="execution-order")
)
assert result["success"] is True
assert result["data"]["found"] is True
assert result["data"]["title"] == "Execution Order"
assert "Manual/execution-order" in result["data"]["url"]
assert len(result["data"]["sections"]) == 2
assert len(result["data"]["code_examples"]) == 1
def test_get_manual_requires_slug():
result = asyncio.run(unity_docs(SimpleNamespace(), action="get_manual"))
assert result["success"] is False
assert "slug" in result["message"]
def test_get_manual_404():
async def mock_fetch(url):
return (404, "")
with patch("services.tools.unity_docs._fetch_url", side_effect=mock_fetch):
result = asyncio.run(
unity_docs(SimpleNamespace(), action="get_manual", slug="nonexistent-page")
)
assert result["success"] is True
assert result["data"]["found"] is False
def test_get_manual_version_fallback():
"""Versioned URL 404s, unversioned succeeds."""
async def mock_fetch(url):
if "/6000.0/" in url:
return (404, "")
return (200, SAMPLE_MANUAL_HTML)
with patch("services.tools.unity_docs._fetch_url", side_effect=mock_fetch):
result = asyncio.run(
unity_docs(
SimpleNamespace(),
action="get_manual",
slug="execution-order",
version="6000.0.38f1",
)
)
assert result["success"] is True
assert result["data"]["found"] is True
assert "/6000.0/" not in result["data"]["url"]
# ---------------------------------------------------------------------------
# get_package_doc action tests (mock _fetch_url_full)
# ---------------------------------------------------------------------------
def test_get_package_doc_success():
async def mock_fetch_full(url):
final = "https://docs.unity3d.com/6000.0/Documentation/Manual/urp/2d-index.html"
return (200, SAMPLE_MANUAL_HTML, final)
with patch("services.tools.unity_docs._fetch_url_full", side_effect=mock_fetch_full):
result = asyncio.run(
unity_docs(
SimpleNamespace(),
action="get_package_doc",
package="com.unity.render-pipelines.universal",
page="2d-index",
pkg_version="17.0",
)
)
assert result["success"] is True
assert result["data"]["found"] is True
assert result["data"]["package"] == "com.unity.render-pipelines.universal"
assert result["data"]["page"] == "2d-index"
assert result["data"]["title"] == "Execution Order"
assert len(result["data"]["sections"]) == 2
assert len(result["data"]["code_examples"]) == 1
# Should use the final (redirected) URL
assert "Manual/urp/2d-index" in result["data"]["url"]
def test_get_package_doc_requires_all_params():
# Missing package
result = asyncio.run(
unity_docs(
SimpleNamespace(),
action="get_package_doc",
page="index",
pkg_version="17.0",
)
)
assert result["success"] is False
assert "package" in result["message"]
# Missing page
result = asyncio.run(
unity_docs(
SimpleNamespace(),
action="get_package_doc",
package="com.unity.render-pipelines.universal",
pkg_version="17.0",
)
)
assert result["success"] is False
assert "page" in result["message"]
# Missing pkg_version
result = asyncio.run(
unity_docs(
SimpleNamespace(),
action="get_package_doc",
package="com.unity.render-pipelines.universal",
page="index",
)
)
assert result["success"] is False
assert "pkg_version" in result["message"]
def test_get_package_doc_404():
async def mock_fetch_full(url):
return (404, "", url)
with patch("services.tools.unity_docs._fetch_url_full", side_effect=mock_fetch_full):
result = asyncio.run(
unity_docs(
SimpleNamespace(),
action="get_package_doc",
package="com.unity.fake-package",
page="index",
pkg_version="1.0",
)
)
assert result["success"] is True
assert result["data"]["found"] is False
# ---------------------------------------------------------------------------
# lookup action tests
# ---------------------------------------------------------------------------
def test_lookup_requires_query():
result = asyncio.run(unity_docs(SimpleNamespace(), action="lookup"))
assert result["success"] is False
assert "query" in result["message"] or "queries" in result["message"]
def test_lookup_single_query():
"""lookup with a single query finds it via ScriptReference."""
async def mock_fetch(url):
if "ScriptReference/Physics" in url:
return (200, SAMPLE_DOC_HTML)
return (404, "")
async def mock_fetch_full(url):
return (404, "", url)
with patch("services.tools.unity_docs._fetch_url", side_effect=mock_fetch), \
patch("services.tools.unity_docs._fetch_url_full", side_effect=mock_fetch_full):
result = asyncio.run(
unity_docs(SimpleNamespace(), action="lookup", query="Physics")
)
assert result["success"] is True
assert result["data"]["found"] is True
assert result["data"]["summary"]["found"] == 1
def test_lookup_batch_queries():
"""lookup with multiple queries searches all in parallel."""
async def mock_fetch(url):
if "ScriptReference/Physics" in url or "ScriptReference/Camera" in url:
return (200, SAMPLE_DOC_HTML)
return (404, "")
async def mock_fetch_full(url):
return (404, "", url)
with patch("services.tools.unity_docs._fetch_url", side_effect=mock_fetch), \
patch("services.tools.unity_docs._fetch_url_full", side_effect=mock_fetch_full):
result = asyncio.run(
unity_docs(SimpleNamespace(), action="lookup",
queries="Physics,Camera,zzz-nonexistent")
)
assert result["success"] is True
assert result["data"]["summary"]["total"] == 3
assert result["data"]["summary"]["found"] == 2
assert result["data"]["summary"]["missed"] == 1
def test_lookup_no_results():
"""lookup with garbage returns found=False with suggestions."""
async def mock_fetch(url):
return (404, "")
async def mock_fetch_full(url):
return (404, "", url)
with patch("services.tools.unity_docs._fetch_url", side_effect=mock_fetch), \
patch("services.tools.unity_docs._fetch_url_full", side_effect=mock_fetch_full):
result = asyncio.run(
unity_docs(SimpleNamespace(), action="lookup", query="zzz-nonexistent-xyz")
)
assert result["success"] is True
assert result["data"]["found"] is False
assert result["data"]["summary"]["missed"] == 1
def test_asset_keyword_detection():
"""Queries with asset keywords trigger project asset search."""
from services.tools.unity_docs import _should_search_assets
assert _should_search_assets("Mesh2D shader") is True
assert _should_search_assets("Lit material") is True
assert _should_search_assets("URP 2D lighting") is True
assert _should_search_assets("default sprite") is True
assert _should_search_assets("Physics.Raycast") is False
assert _should_search_assets("NavMeshAgent") is False
assert _should_search_assets("execution-order") is False
def test_build_asset_search_terms():
"""Extract meaningful search terms from query, infer filter types."""
from services.tools.unity_docs import _build_asset_search_terms
# "Mesh2D shader" → search for *mesh2d* with filter_type=Shader
terms = _build_asset_search_terms("Mesh2D shader")
assert len(terms) >= 1
assert any("mesh2d" in t.get("search_pattern", "") for t in terms)
assert any(t.get("filter_type") == "Shader" for t in terms)
# "MeshRenderer 2D lights" → search for *meshrenderer*, *lights* (2d triggers keyword)
terms = _build_asset_search_terms("MeshRenderer 2D lights")
assert len(terms) >= 1
assert any("meshrenderer" in t.get("search_pattern", "") for t in terms)
# "Physics.Raycast" → no asset search terms (no asset keywords)
# (This won't be called since _should_search_assets returns False, but test the function)
terms = _build_asset_search_terms("Physics.Raycast")
assert len(terms) >= 1 # Still extracts terms, just won't be triggered