From a60635f88a95665d44aa26f83ca6225bc0c6ca4c Mon Sep 17 00:00:00 2001 From: Song Seung Hu Date: Thu, 23 Apr 2026 23:24:43 +0900 Subject: [PATCH] Address hardening issues 35-47 --- .github/ISSUE_TEMPLATE/bug-report.md | 31 +++++++ .github/ISSUE_TEMPLATE/docs-issue.md | 21 +++++ .../ISSUE_TEMPLATE/release-package-issue.md | 25 ++++++ .github/workflows/python-sidecar-ci.yml | 31 +++++++ .github/workflows/release-sidecar.yml | 84 +++++++++++++++++++ CONTRIBUTING.md | 48 +++++++++++ .../Documentation~/README.md | 8 +- .../ResourceIndexing/ApplyUiBlueprintTool.cs | 33 +++++++- .../Editor/UnityResourceRagEditorSettings.cs | 18 +++- .../Editor/UnityResourceRagLocalRunner.cs | 2 +- ...UnityResourceRagRuntimeBootstrapService.cs | 2 +- .../com.hanjo92.unity-resource-rag/README.md | 10 +-- .../Samples~/Blueprints/README.md | 21 +++++ .../package.json | 7 ++ README.md | 7 +- pipeline/evaluation/models.py | 29 ++++++- pipeline/mcp/local_runner.py | 2 +- pipeline/mcp/unity_http.py | 29 +++++-- pipeline/retrieval/embedding_bridge.py | 5 ++ tests/test_evaluation_fixtures.py | 42 +++++++++- tests/test_gateway_image_embedding.py | 33 ++++++++ tests/test_local_runner.py | 33 ++++++++ tests/test_python_runtime_requirements.py | 35 ++++++++ tests/test_retrieval_embedding_bridge.py | 63 ++++++++++++++ tests/test_unity_http.py | 51 +++++++++++ tests/test_unity_package_source.py | 54 ++++++++++++ 26 files changed, 694 insertions(+), 30 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/docs-issue.md create mode 100644 .github/ISSUE_TEMPLATE/release-package-issue.md create mode 100644 .github/workflows/python-sidecar-ci.yml create mode 100644 .github/workflows/release-sidecar.yml create mode 100644 CONTRIBUTING.md create mode 100644 Packages/com.hanjo92.unity-resource-rag/Samples~/Blueprints/README.md create mode 100644 tests/test_python_runtime_requirements.py create mode 100644 tests/test_unity_package_source.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..38d0f16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Report a reproducible bug in Unity Resource RAG +title: "[bug] " +labels: ["bug"] +assignees: [] +--- + +## Environment + +- Unity version: +- OS: +- Package version: +- Sidecar version: +- Python version: + +## Reproduction + +1. +2. +3. + +## Expected + +## Actual + +## Notes + +- Logs: +- Screenshots or recordings: +- Extra context: diff --git a/.github/ISSUE_TEMPLATE/docs-issue.md b/.github/ISSUE_TEMPLATE/docs-issue.md new file mode 100644 index 0000000..5bfb184 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs-issue.md @@ -0,0 +1,21 @@ +--- +name: Docs issue +about: Report a documentation problem or missing clarification +title: "[docs] " +labels: ["documentation"] +assignees: [] +--- + +## Affected page or link + +- Page: +- Link: + +## What needs correction + +## Expected correction + +## Notes + +- Suggested wording: +- Related issue or PR: diff --git a/.github/ISSUE_TEMPLATE/release-package-issue.md b/.github/ISSUE_TEMPLATE/release-package-issue.md new file mode 100644 index 0000000..8bfe45b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release-package-issue.md @@ -0,0 +1,25 @@ +--- +name: Release or package issue +about: Report a problem with an installed package or release artifact +title: "[release] " +labels: ["enhancement"] +assignees: [] +--- + +## Environment + +- Package install method: +- Sidecar bundle or source checkout: +- Artifact version: + +## Problem + +## Expected + +## Actual + +## Notes + +- Install path or source path: +- Logs: +- Related release notes: diff --git a/.github/workflows/python-sidecar-ci.yml b/.github/workflows/python-sidecar-ci.yml new file mode 100644 index 0000000..7c4c8cc --- /dev/null +++ b/.github/workflows/python-sidecar-ci.yml @@ -0,0 +1,31 @@ +name: Python Sidecar CI + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: python -m pip install -r requirements.txt + + - name: Run unit tests + run: python -m unittest discover -s tests -v + + - name: Compile Python package + run: python -m compileall pipeline + + - name: Build sidecar bundle smoke test + run: | + tmpdir="$(mktemp -d)" + python scripts/build_sidecar_bundle.py --output-dir "$tmpdir" diff --git a/.github/workflows/release-sidecar.yml b/.github/workflows/release-sidecar.yml new file mode 100644 index 0000000..b41dea7 --- /dev/null +++ b/.github/workflows/release-sidecar.yml @@ -0,0 +1,84 @@ +name: Publish sidecar release asset + +on: + push: + tags: + - "v*" + - "*.*.*" + release: + types: + - published + +permissions: + contents: write + +jobs: + publish-sidecar: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install requirements + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then + python -m pip install -r requirements.txt + fi + + - name: Build sidecar bundle and archive it + id: build + run: | + build_json="$(python scripts/build_sidecar_bundle.py --output-dir dist)" + echo "$build_json" + + bundle_root="$(BUILD_JSON="$build_json" python - <<'PY' +import json +import os + +payload = json.loads(os.environ["BUILD_JSON"]) +print(payload["bundleRoot"]) +PY +)" + version="$(BUILD_JSON="$build_json" python - <<'PY' +import json +import os + +payload = json.loads(os.environ["BUILD_JSON"]) +print(payload["version"]) +PY +)" + + zip_path="dist/unity-resource-rag-sidecar-${version}.zip" + python - <<'PY' "$bundle_root" "$zip_path" +from pathlib import Path +import shutil +import sys + +bundle_root = Path(sys.argv[1]).resolve() +zip_path = Path(sys.argv[2]).resolve() + +shutil.make_archive( + str(zip_path.with_suffix("")), + "zip", + root_dir=bundle_root.parent, + base_dir=bundle_root.name, +) +PY + + echo "bundle_root=$bundle_root" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "zip_path=$zip_path" >> "$GITHUB_OUTPUT" + + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.build.outputs.zip_path }} + tag_name: ${{ github.event.release.tag_name || github.ref_name }} + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..98be7bb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +Thanks for helping improve Unity Resource RAG. + +## Local setup + +Use Python 3.11 or 3.12 for the sidecar and test suite. + +```bash +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +``` + +If `python3.12` is not available, use another Python 3.11+ interpreter such as `python3.11`, `python3`, or the equivalent Windows launcher. + +## Verification + +Run the Python checks before you open a PR: + +```bash +python -m unittest discover -s tests -v +python -m compileall pipeline +python scripts/build_sidecar_bundle.py --output-dir dist +``` + +The bundle smoke test should finish without errors and leave a portable sidecar bundle under the output directory. + +## Unity smoke check + +Do a minimal Unity validation before asking for review: + +1. Open the project in a supported Unity editor. +2. Confirm `Window > Unity Resource RAG` opens. +3. Run `Quick Setup`. +4. Refresh the Readiness Dashboard and confirm the editor can see the package and sidecar path you expect. + +If the change touches the package surface or editor integration, also verify the package loads in a clean project or a fresh project checkout. + +## Quality cases + +Use the existing `quality-case-report` issue template when you want to record a real validation run. + +- Keep the report tied to one concrete project or screen. +- Include the reference source, blueprint path, and before/after artifacts. +- Capture what matched well, what did not, and the most useful follow-up ideas. + +Do not replace the quality-case template with a generic bug report; it exists to preserve real project validation history. diff --git a/Packages/com.hanjo92.unity-resource-rag/Documentation~/README.md b/Packages/com.hanjo92.unity-resource-rag/Documentation~/README.md index 946b74c..9fc44e2 100644 --- a/Packages/com.hanjo92.unity-resource-rag/Documentation~/README.md +++ b/Packages/com.hanjo92.unity-resource-rag/Documentation~/README.md @@ -8,10 +8,10 @@ 저장소 루트 문서: -- `docs/asset-aware-ui-rag-architecture.md` -- `specs/resource-schema.md` -- `specs/retrieval-ranking.md` -- `specs/ui-assembly-contract.md` +- [docs/asset-aware-ui-rag-architecture.md](https://github.com/Hanjo92/unity-resource-rag/blob/main/docs/asset-aware-ui-rag-architecture.md) +- [specs/resource-schema.md](https://github.com/Hanjo92/unity-resource-rag/blob/main/specs/resource-schema.md) +- [specs/retrieval-ranking.md](https://github.com/Hanjo92/unity-resource-rag/blob/main/specs/retrieval-ranking.md) +- [specs/ui-assembly-contract.md](https://github.com/Hanjo92/unity-resource-rag/blob/main/specs/ui-assembly-contract.md) 루트 문서들은 sidecar pipeline까지 포함한 전체 아키텍처를 설명하고, 패키지 내부 문서는 UPM 안에 들어가는 Unity 코드에 집중한다. diff --git a/Packages/com.hanjo92.unity-resource-rag/Editor/ResourceIndexing/ApplyUiBlueprintTool.cs b/Packages/com.hanjo92.unity-resource-rag/Editor/ResourceIndexing/ApplyUiBlueprintTool.cs index dbc5f97..4d8f9d0 100644 --- a/Packages/com.hanjo92.unity-resource-rag/Editor/ResourceIndexing/ApplyUiBlueprintTool.cs +++ b/Packages/com.hanjo92.unity-resource-rag/Editor/ResourceIndexing/ApplyUiBlueprintTool.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; +using UnityEditor.PackageManager; using UnityEngine; namespace UnityResourceRag.Editor.ResourceIndexing @@ -141,7 +142,7 @@ private static bool TryLoadBlueprint( if (!string.IsNullOrWhiteSpace(parameters.blueprintPath)) { - string resolvedPath = ResourceCatalogStorage.ResolveProjectPath(parameters.blueprintPath, parameters.blueprintPath); + string resolvedPath = ResolveBlueprintPath(parameters.blueprintPath); if (!File.Exists(resolvedPath)) { error = $"Blueprint file not found: {resolvedPath}"; @@ -162,6 +163,36 @@ private static bool TryLoadBlueprint( return false; } + private static string ResolveBlueprintPath(string blueprintPath) + { + string trimmedPath = blueprintPath?.Trim(); + if (string.IsNullOrWhiteSpace(trimmedPath)) + { + return string.Empty; + } + + if (Path.IsPathRooted(trimmedPath)) + { + return Path.GetFullPath(trimmedPath).Replace('\\', '/'); + } + + string normalizedPath = trimmedPath.Replace('\\', '/'); + if (normalizedPath.Equals("Samples~", StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith("Samples~/", StringComparison.OrdinalIgnoreCase)) + { + PackageInfo packageInfo = PackageInfo.FindForAssembly(Assembly.GetExecutingAssembly()); + if (packageInfo != null && !string.IsNullOrWhiteSpace(packageInfo.resolvedPath)) + { + string relativeSamplePath = normalizedPath.Equals("Samples~", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : normalizedPath.Substring("Samples~/".Length); + return Path.GetFullPath(Path.Combine(packageInfo.resolvedPath, "Samples~", relativeSamplePath)).Replace('\\', '/'); + } + } + + return ResourceCatalogStorage.ResolveProjectPath(trimmedPath, trimmedPath); + } + private static List ValidateBlueprint(UiBlueprintDocument blueprint) { var issues = new List(); diff --git a/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagEditorSettings.cs b/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagEditorSettings.cs index 66096f5..5df7d34 100644 --- a/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagEditorSettings.cs +++ b/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagEditorSettings.cs @@ -42,6 +42,9 @@ public sealed class UnityResourceRagEditorSettings : ScriptableSingleton + string.Format("{0}.{1}+", MinimumPythonMajorVersion, MinimumPythonMinorVersion); + public static string DefaultCodexAuthFilePath() { string codexHome = Environment.GetEnvironmentVariable("CODEX_HOME"); @@ -653,6 +659,8 @@ private static IEnumerable EnumeratePythonCandidates(string sidecarRepoR { candidates.AddRange(new[] { + "py -3.12", + "py -3.11", "py -3", "py", "python", @@ -663,11 +671,17 @@ private static IEnumerable EnumeratePythonCandidates(string sidecarRepoR { candidates.AddRange(new[] { + "/opt/homebrew/bin/python3.12", + "/opt/homebrew/bin/python3.11", + "/usr/local/bin/python3.12", + "/usr/local/bin/python3.11", "/opt/homebrew/Caskroom/miniforge/base/bin/python3", "/opt/homebrew/bin/python3", "/opt/homebrew/bin/python", "/usr/local/bin/python3", "/usr/local/bin/python", + "python3.12", + "python3.11", "python3", "python", }); @@ -709,7 +723,7 @@ private static bool CanRunSidecarImports(string pythonExecutable, string sidecar ProcessStartInfo startInfo = CreateCommandStartInfo( pythonExecutable, string.IsNullOrWhiteSpace(sidecarRepoRoot) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : sidecarRepoRoot, - new[] { "-c", "import pydantic" }); + new[] { "-c", MinimumPythonVersionProbe + "; import pydantic" }); startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; startInfo.UseShellExecute = false; @@ -755,7 +769,7 @@ private static bool CanRunBasicPythonProbe(string pythonExecutable, string sidec ProcessStartInfo startInfo = CreateCommandStartInfo( pythonExecutable, string.IsNullOrWhiteSpace(sidecarRepoRoot) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : sidecarRepoRoot, - new[] { "-c", "import sys; print(sys.executable)" }); + new[] { "-c", MinimumPythonVersionProbe }); startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; startInfo.UseShellExecute = false; diff --git a/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagLocalRunner.cs b/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagLocalRunner.cs index 5064bb3..1fec81f 100644 --- a/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagLocalRunner.cs +++ b/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagLocalRunner.cs @@ -231,7 +231,7 @@ private static PreparedToolRun PrepareToolRun(UnityResourceRagEditorSettings set { immediateFailure = new UnityResourceRagLocalToolResult { - Error = "No Python command that can load the sidecar requirements was found. Point Python Command to an interpreter where `pip install -r requirements.txt` has already been run.", + Error = $"No Python {UnityResourceRagEditorSettings.RequiredPythonVersionLabel} command that can load the sidecar requirements was found. Point Python Command to a Python {UnityResourceRagEditorSettings.RequiredPythonVersionLabel} interpreter where `pip install -r requirements.txt` has already been run.", }; return null; } diff --git a/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagRuntimeBootstrapService.cs b/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagRuntimeBootstrapService.cs index e655fcb..fe844b9 100644 --- a/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagRuntimeBootstrapService.cs +++ b/Packages/com.hanjo92.unity-resource-rag/Editor/UnityResourceRagRuntimeBootstrapService.cs @@ -34,7 +34,7 @@ public static bool TryRunAsync(UnityResourceRagEditorSettings settings, Action/` 아래에 portable sidecar bundle을 만들고, Unity 창의 `Sidecar Runtime Root`에서 바로 가리킬 수 있는 형태로 정리한다. +릴리스에서는 같은 내용을 `unity-resource-rag-sidecar-.zip` asset으로 받을 수 있다. 가능하면 Unity package version과 같은 ``의 sidecar zip을 내려받아 압축을 풀고, 그 폴더를 `Sidecar Runtime Root`로 지정한다. + ## 빠른 시작 ### 기본 흐름 diff --git a/pipeline/evaluation/models.py b/pipeline/evaluation/models.py index 530a6d5..b8740f4 100644 --- a/pipeline/evaluation/models.py +++ b/pipeline/evaluation/models.py @@ -5,6 +5,8 @@ SCHEMA_VERSION = "0.3.0" +ALLOWED_INTERACTION_LEVELS = {"static", "read_only", "interactive"} +ALLOWED_BINDING_POLICIES = {"require_confident", "review_if_uncertain", "best_match"} class BenchmarkFixtureError(ValueError): @@ -27,11 +29,18 @@ def _require_str(payload: Mapping[str, Any], key: str) -> str: def _require_int(payload: Mapping[str, Any], key: str) -> int: value = payload.get(key) - if not isinstance(value, int): + if type(value) is not int: raise BenchmarkFixtureError(f"Expected integer at '{key}'.") return value +def _require_int_ge(payload: Mapping[str, Any], key: str, minimum: int) -> int: + value = _require_int(payload, key) + if value < minimum: + raise BenchmarkFixtureError(f"Expected integer >= {minimum} at '{key}'.") + return value + + def _require_float(payload: Mapping[str, Any], key: str) -> float: value = payload.get(key) if not isinstance(value, (int, float)): @@ -46,6 +55,14 @@ def _require_list(payload: Mapping[str, Any], key: str) -> list[Any]: return value +def _require_allowed_str(payload: Mapping[str, Any], key: str, allowed: set[str]) -> str: + value = _require_str(payload, key) + if value not in allowed: + allowed_values = ", ".join(sorted(allowed)) + raise BenchmarkFixtureError(f"Expected one of {{{allowed_values}}} at '{key}'.") + return value + + def _require_schema_version(payload: Mapping[str, Any]) -> str: schema_version = _require_str(payload, "schemaVersion") if schema_version != SCHEMA_VERSION: @@ -168,9 +185,13 @@ def from_dict(cls, payload: Mapping[str, Any]) -> "BenchmarkRegionFixture": "h": _require_float(normalized_bounds, "h"), }, preferred_asset_kinds=preferred_asset_kinds, - repeat_count=int(payload.get("repeatCount", 1)), - interaction_level=str(payload.get("interactionLevel", "static")), - binding_policy=str(payload.get("bindingPolicy", "require_confident")), + repeat_count=_require_int_ge(payload, "repeatCount", 1) if "repeatCount" in payload else 1, + interaction_level=_require_allowed_str(payload, "interactionLevel", ALLOWED_INTERACTION_LEVELS) + if "interactionLevel" in payload + else "static", + binding_policy=_require_allowed_str(payload, "bindingPolicy", ALLOWED_BINDING_POLICIES) + if "bindingPolicy" in payload + else "require_confident", min_score=float(payload["minScore"]) if payload.get("minScore") is not None else None, ) diff --git a/pipeline/mcp/local_runner.py b/pipeline/mcp/local_runner.py index 4dab468..dd20b91 100644 --- a/pipeline/mcp/local_runner.py +++ b/pipeline/mcp/local_runner.py @@ -106,7 +106,7 @@ def _capture_result_tool(args: dict[str, Any]) -> dict[str, Any]: raise ToolExecutionError("Capture requires `verify_request` or screenshot arguments such as `view_target`.") request_arguments.setdefault("action", "screenshot") - request_arguments["include_image"] = bool(args.get("include_image", False)) + request_arguments.setdefault("include_image", bool(args.get("include_image", False))) try: response_payload = get_unity_http_client(unity_mcp_url, timeout_ms).request( diff --git a/pipeline/mcp/unity_http.py b/pipeline/mcp/unity_http.py index 3e77ccb..cfda440 100644 --- a/pipeline/mcp/unity_http.py +++ b/pipeline/mcp/unity_http.py @@ -128,19 +128,30 @@ def request(self, method: str, params: dict[str, Any] | None, request_id: int) - if self._session_id is None: self._initialize_session() - headers = { - "Content-Type": "application/json", - "Accept": STREAMABLE_HTTP_ACCEPT, - SESSION_HEADER: self._session_id or "", - } payload = { "jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}, } - _, response = _perform_http_post(self._url, payload, headers, self._timeout_ms) - return response + retried = False + + while True: + headers = { + "Content-Type": "application/json", + "Accept": STREAMABLE_HTTP_ACCEPT, + SESSION_HEADER: self._session_id or "", + } + try: + _, response = _perform_http_post(self._url, payload, headers, self._timeout_ms) + return response + except UnityMcpHttpError as exc: + if retried or not self._is_session_related_error(exc): + raise + + self._session_id = None + self._initialize_session() + retried = True def _initialize_session(self) -> None: headers = { @@ -175,6 +186,10 @@ def _initialize_session(self) -> None: self._session_id = session_id + @staticmethod + def _is_session_related_error(exc: UnityMcpHttpError) -> bool: + return exc.status_code in {400, 401, 403, 404, 409, 410} + _CLIENT_CACHE: dict[tuple[str, int], UnityMcpHttpClient] = {} diff --git a/pipeline/retrieval/embedding_bridge.py b/pipeline/retrieval/embedding_bridge.py index e4ca583..a056be5 100644 --- a/pipeline/retrieval/embedding_bridge.py +++ b/pipeline/retrieval/embedding_bridge.py @@ -124,6 +124,11 @@ def _extract_embedding_payload(response: Mapping[str, Any]) -> Any: if isinstance(first, Mapping): token_weights = first.get("tokenWeights") or first.get("embedding") if token_weights is not None: + if len(items) > 1: + raise EmbeddingBridgeError( + f"Gateway embedding response contains a batch of {len(items)} items; " + "normalize one item at a time." + ) return {"container": first, "embedding": token_weights} for key in EMBEDDING_VECTOR_KEYS: diff --git a/tests/test_evaluation_fixtures.py b/tests/test_evaluation_fixtures.py index f4bd4fa..815effd 100644 --- a/tests/test_evaluation_fixtures.py +++ b/tests/test_evaluation_fixtures.py @@ -11,7 +11,12 @@ sys.path.insert(0, str(REPO_ROOT)) from pipeline.evaluation.fixtures import load_benchmark_report, load_benchmark_suite -from pipeline.evaluation.models import BenchmarkRegionFixture, BenchmarkRetrievalFixture, BenchmarkScreenFixture +from pipeline.evaluation.models import ( + BenchmarkFixtureError, + BenchmarkRegionFixture, + BenchmarkRetrievalFixture, + BenchmarkScreenFixture, +) from pipeline.evaluation.report_models import BenchmarkRunReport, BenchmarkRunSummary @@ -62,7 +67,7 @@ def test_report_model_round_trip(self) -> None: self.assertEqual(summary.to_dict(), report.summary.to_dict()) def test_invalid_fixture_raises_clear_error(self) -> None: - with self.assertRaises(ValueError): + with self.assertRaises(BenchmarkFixtureError): BenchmarkRetrievalFixture.from_dict( { "schemaVersion": "0.3.0", @@ -78,6 +83,39 @@ def test_invalid_fixture_raises_clear_error(self) -> None: } ) + def test_retrieval_fixture_rejects_invalid_repeat_count_and_planner_values(self) -> None: + base_region = { + "regionId": "broken_region", + "regionType": "asset_query", + "queryText": "broken", + "normalizedBounds": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}, + "preferredAssetKinds": ["sprite"], + "repeatCount": 1, + "interactionLevel": "static", + "bindingPolicy": "require_confident", + } + + invalid_cases = [ + ("repeatCount zero", {**base_region, "repeatCount": 0}), + ("repeatCount negative", {**base_region, "repeatCount": -1}), + ("repeatCount float", {**base_region, "repeatCount": 1.5}), + ("repeatCount string", {**base_region, "repeatCount": "2"}), + ("repeatCount bool", {**base_region, "repeatCount": True}), + ("interactionLevel invalid", {**base_region, "interactionLevel": "hover"}), + ("bindingPolicy invalid", {**base_region, "bindingPolicy": "hold_if_uncertain"}), + ] + + for case_name, region in invalid_cases: + with self.subTest(case_name=case_name): + with self.assertRaises(BenchmarkFixtureError): + BenchmarkRetrievalFixture.from_dict( + { + "schemaVersion": "0.3.0", + "screenName": "broken_screen", + "regions": [region], + } + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_gateway_image_embedding.py b/tests/test_gateway_image_embedding.py index 83ca30b..2b30c46 100644 --- a/tests/test_gateway_image_embedding.py +++ b/tests/test_gateway_image_embedding.py @@ -75,6 +75,39 @@ def test_run_image_embedding_accepts_precomputed_visual_tokens(self) -> None: self.assertEqual(embedding["cell_1_1_bright"], 0.5) self.assertEqual(embedding["orientation_landscape"], 0.25) + def test_run_image_embedding_returns_both_items_for_batches(self) -> None: + result = run_image_embedding( + { + "capability": CAPABILITY_NAME, + "input": { + "images": [ + { + "label": "reward_preview", + "visualTokens": [ + "orientation_landscape", + "palette_warm", + "cell_1_1_bright", + ], + }, + { + "label": "inventory_preview", + "visualTokens": [ + "orientation_landscape", + "palette_cool", + "cell_1_1_dark", + ], + }, + ] + }, + "outputSchema": "image_embedding_v1", + } + ) + + self.assertEqual(len(result["output"]["items"]), 2) + self.assertIsNone(result["output"]["embedding"]) + self.assertEqual(result["usage"]["inputImages"], 2) + self.assertGreater(result["usage"]["totalTokens"], 0) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_local_runner.py b/tests/test_local_runner.py index 7579b86..1c51bd7 100644 --- a/tests/test_local_runner.py +++ b/tests/test_local_runner.py @@ -103,6 +103,39 @@ def test_capture_result_resolves_assets_relative_screenshot_path(self) -> None: self.assertEqual(result["payload"]["capturedPath"], expected_path) self.assertFalse(result["payload"]["request"]["include_image"]) + def test_capture_result_preserves_nested_include_image_request(self) -> None: + fake_response = { + "result": { + "content": [ + { + "type": "text", + "text": '{"success": true, "data": {"path": "Assets/Screenshots/reward.png"}}', + } + ] + } + } + fake_client = mock.Mock() + fake_client.request.return_value = fake_response + + with mock.patch("pipeline.mcp.local_runner.get_unity_http_client", return_value=fake_client): + result = run_tool( + "capture_result", + { + "unity_project_path": "/tmp/UnityProject", + "verify_request": { + "tool": "manage_camera", + "parameters": { + "action": "screenshot", + "view_target": "RewardPopupCanvas", + "include_image": True, + }, + }, + }, + ) + + self.assertTrue(result["ok"]) + self.assertTrue(result["payload"]["request"]["include_image"]) + def test_run_tool_extracts_wrapped_verification_repair_payload(self) -> None: wrapped_result = { "content": [ diff --git a/tests/test_python_runtime_requirements.py b/tests/test_python_runtime_requirements.py new file mode 100644 index 0000000..f33735d --- /dev/null +++ b/tests/test_python_runtime_requirements.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +README_PATH = REPO_ROOT / "README.md" +EDITOR_SETTINGS_PATH = ( + REPO_ROOT + / "Packages" + / "com.hanjo92.unity-resource-rag" + / "Editor" + / "UnityResourceRagEditorSettings.cs" +) + + +class PythonRuntimeRequirementTests(unittest.TestCase): + def test_readme_python_setup_uses_versioned_python_command(self) -> None: + readme = README_PATH.read_text(encoding="utf-8") + + self.assertIn("python3.12 -m venv .venv", readme) + self.assertIn("Python 3.11+", readme) + self.assertIn("python3.11", readme) + + def test_unity_python_detection_rejects_older_interpreters(self) -> None: + source = EDITOR_SETTINGS_PATH.read_text(encoding="utf-8") + + self.assertIn("MinimumPythonMajorVersion = 3", source) + self.assertIn("MinimumPythonMinorVersion = 11", source) + self.assertIn("sys.version_info >= (3, 11)", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_retrieval_embedding_bridge.py b/tests/test_retrieval_embedding_bridge.py index e4090ee..fb82f1e 100644 --- a/tests/test_retrieval_embedding_bridge.py +++ b/tests/test_retrieval_embedding_bridge.py @@ -135,6 +135,38 @@ def test_score_gateway_text_embedding_response_accepts_capability_item_shape(sel self.assertGreater(scores["reward-popup-frame"], scores["inventory-panel-frame"]) + def test_normalize_gateway_text_embedding_response_rejects_multi_item_batches(self) -> None: + response = { + "status": "ok", + "capability": "text_embedding", + "output": { + "scheme": "token-frequency-v1", + "items": [ + { + "index": 0, + "text": "reward popup frame", + "tokenWeights": { + "reward": 0.4, + "popup": 0.3, + "frame": 0.3, + }, + }, + { + "index": 1, + "text": "inventory panel frame", + "tokenWeights": { + "inventory": 0.5, + "panel": 0.25, + "frame": 0.25, + }, + }, + ], + }, + } + + with self.assertRaisesRegex(EmbeddingBridgeError, "batch"): + normalize_gateway_text_embedding_response(response) + def test_generic_embedding_helpers_accept_image_embedding_preview_shape(self) -> None: vector_index = _image_vector_index() response = { @@ -162,6 +194,37 @@ def test_generic_embedding_helpers_accept_image_embedding_preview_shape(self) -> self.assertIn("palette_warm", embedding) self.assertGreater(scores["reward-preview"], scores["inventory-preview"]) + def test_normalize_gateway_sparse_embedding_response_rejects_multi_item_batches(self) -> None: + response = { + "status": "ok", + "capability": "image_embedding", + "output": { + "scheme": "visual-token-sparse-v1", + "preview": True, + "items": [ + { + "index": 0, + "tokenWeights": { + "orientation_landscape": 0.4, + "palette_warm": 0.35, + "cell_1_1_bright": 0.25, + }, + }, + { + "index": 1, + "tokenWeights": { + "orientation_landscape": 0.3, + "palette_cool": 0.4, + "cell_1_1_dark": 0.3, + }, + }, + ], + }, + } + + with self.assertRaisesRegex(EmbeddingBridgeError, "batch"): + normalize_gateway_sparse_embedding_response(response) + def test_optional_gateway_embedding_falls_back_to_baseline(self) -> None: vector_index = _vector_index() query_text = "inventory panel frame" diff --git a/tests/test_unity_http.py b/tests/test_unity_http.py index 84611cd..36e02c9 100644 --- a/tests/test_unity_http.py +++ b/tests/test_unity_http.py @@ -1,7 +1,9 @@ from __future__ import annotations +import io import sys import unittest +from urllib import error as urllib_error from unittest import mock @@ -75,3 +77,52 @@ def fake_urlopen(request, timeout=0): self.assertNotIn(SESSION_HEADER, {str(key).lower(): value for key, value in calls[0]["headers"].items()}) self.assertEqual(second_headers[SESSION_HEADER], "session-123") self.assertEqual(third_headers[SESSION_HEADER], "session-123") + + def test_client_reinitializes_after_session_error_and_retries_once(self) -> None: + calls: list[dict[str, object]] = [] + init_sessions = iter(["session-123", "session-456"]) + + def fake_urlopen(request, timeout=0): + headers = dict(request.header_items()) + body = request.data.decode("utf-8") + calls.append({"headers": headers, "body": body}) + + if "\"method\": \"initialize\"" in body: + return _FakeResponse( + "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"protocolVersion\":\"2024-11-05\"}}\r\n\r\n", + { + "content-type": "text/event-stream", + "mcp-session-id": next(init_sessions), + }, + ) + + if len(calls) == 2: + raise urllib_error.HTTPError( + request.full_url, + 404, + "Not Found", + hdrs=None, + fp=io.BytesIO(b""), + ) + + return _FakeResponse( + "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[{\"name\":\"apply_ui_blueprint\"}]}}\r\n\r\n", + {"content-type": "text/event-stream", "mcp-session-id": "session-456"}, + ) + + client = UnityMcpHttpClient("http://127.0.0.1:8080/mcp", 3000) + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + result = client.request("tools/list", {}, 1) + follow_up = client.request("tools/list", {}, 2) + + self.assertEqual(result["result"]["tools"][0]["name"], "apply_ui_blueprint") + self.assertEqual(follow_up["result"]["tools"][0]["name"], "apply_ui_blueprint") + self.assertEqual(len(calls), 5) + self.assertEqual( + {str(key).lower(): value for key, value in calls[1]["headers"].items()}[SESSION_HEADER], + "session-123", + ) + self.assertEqual( + {str(key).lower(): value for key, value in calls[3]["headers"].items()}[SESSION_HEADER], + "session-456", + ) diff --git a/tests/test_unity_package_source.py b/tests/test_unity_package_source.py new file mode 100644 index 0000000..1a83bdd --- /dev/null +++ b/tests/test_unity_package_source.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_ROOT = REPO_ROOT / "Packages" / "com.hanjo92.unity-resource-rag" +APPLY_TOOL_PATH = PACKAGE_ROOT / "Editor" / "ResourceIndexing" / "ApplyUiBlueprintTool.cs" +PACKAGE_JSON_PATH = PACKAGE_ROOT / "package.json" +PACKAGE_README_PATH = PACKAGE_ROOT / "README.md" +PACKAGE_DOC_README_PATH = PACKAGE_ROOT / "Documentation~" / "README.md" +SAMPLE_README_PATH = PACKAGE_ROOT / "Samples~" / "Blueprints" / "README.md" + + +class UnityPackageSourceTests(unittest.TestCase): + def test_apply_blueprint_accepts_package_sample_paths(self) -> None: + source = APPLY_TOOL_PATH.read_text(encoding="utf-8") + + self.assertIn("ResolveBlueprintPath", source) + self.assertIn("Samples~/", source) + self.assertIn("PackageInfo.FindForAssembly", source) + + def test_package_docs_do_not_depend_on_repo_relative_parent_links(self) -> None: + for path in (PACKAGE_README_PATH, PACKAGE_DOC_README_PATH): + with self.subTest(path=path.name): + text = path.read_text(encoding="utf-8") + self.assertNotIn("../../", text) + self.assertIn("https://github.com/Hanjo92/unity-resource-rag", text) + + def test_package_json_registers_blueprint_samples(self) -> None: + payload = json.loads(PACKAGE_JSON_PATH.read_text(encoding="utf-8")) + samples = payload.get("samples") or [] + + self.assertIn( + { + "displayName": "Blueprint samples for project-specific bindings", + "description": "Project-specific blueprint examples that reference project assets and a custom UI component binding. Use the template file as a starting point for your own retrieval and binding setup.", + "path": "Samples~/Blueprints", + }, + samples, + ) + + def test_sample_readme_marks_project_specific_dependencies(self) -> None: + text = SAMPLE_README_PATH.read_text(encoding="utf-8") + + self.assertIn("Assets/UI/...", text) + self.assertIn("MyGame.UI.SafeAreaFitter", text) + self.assertIn("프로젝트 전용 예시", text) + + +if __name__ == "__main__": + unittest.main()