chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 28581c55743854f40bc0f3f4f52ae1f1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6c928dbbf09c0412393726aeb9208bc4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,267 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 9
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 705507994}
m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 12
m_GIWorkflowMode: 1
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_FinalGather: 0
m_FinalGatherFiltering: 1
m_FinalGatherRayCount: 256
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 1
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 500
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 500
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 2
m_PVRDenoiserTypeDirect: 0
m_PVRDenoiserTypeIndirect: 0
m_PVRDenoiserTypeAO: 0
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 0
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 5
m_PVRFilteringGaussRadiusAO: 2
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 2
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
accuratePlacement: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &705507993
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 705507995}
- component: {fileID: 705507994}
m_Layer: 0
m_Name: Directional Light
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &705507994
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 705507993}
m_Enabled: 1
serializedVersion: 8
m_Type: 1
m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1}
m_Intensity: 1
m_Range: 10
m_SpotAngle: 30
m_CookieSize: 10
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_Lightmapping: 1
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 6570
m_UseColorTemperature: 0
m_ShadowRadius: 0
m_ShadowAngle: 0
--- !u!4 &705507995
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 705507993}
m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261}
m_LocalPosition: {x: 0, y: 3, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_Children: []
m_Father: {fileID: 0}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0}
--- !u!1 &963194225
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 963194228}
- component: {fileID: 963194227}
- component: {fileID: 963194226}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!81 &963194226
AudioListener:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 963194225}
m_Enabled: 1
--- !u!20 &963194227
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 963194225}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 1
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
m_projectionMatrixMode: 1
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_GateFitMode: 2
m_FocalLength: 50
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 60
orthographic: 0
orthographic size: 5
m_Depth: -1
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &963194228
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInternal: {fileID: 0}
m_GameObject: {fileID: 963194225}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1, z: -10}
m_LocalScale: {x: 1, y: 1, z: 1}
m_Children: []
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9fc0d4010bbf28b4594072e72b8655ab
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3d704438cd4fd4c91ba291553bb87f44
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,186 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 9
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 12
m_GIWorkflowMode: 1
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_FinalGather: 0
m_FinalGatherFiltering: 1
m_FinalGatherRayCount: 256
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 1
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 5
m_PVRFilteringGaussRadiusAO: 2
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 2
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
accuratePlacement: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &254391479
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 254391480}
m_Layer: 0
m_Name: ComponentHost
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &254391480
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 254391479}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1484088777
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1484088778}
m_Layer: 0
m_Name: GameObject
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1484088778
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1484088777}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 2.6174865, y: -0.32601717, z: 1.2447326}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f0a0a216af4f84968bba4ecd4070568d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fe422ce9e144a4a348d4372fd00afd17
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
using UnityEngine;
public class Bouncer : MonoBehaviour
{
public float speed = 1f;
public float height = 2f;
private Vector3 startPos;
void Start()
{
startPos = transform.position;
}
void Update()
{
float newY = startPos.y + Mathf.Abs(Mathf.Sin(Time.time * speed)) * height;
transform.position = new Vector3(startPos.x, newY, startPos.z);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9428a7e51070a45c6b38f707ecb15420
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,10 @@
using UnityEngine;
using System.Collections;
public class Hello : MonoBehaviour
{
void Start()
{
Debug.Log("Hello World");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bebdf68a6876b425494ee770d20f70ef
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dfbabf507ab1245178d1a8e745d8d283
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e5441db2ad88a4bc3a8f0868c9471142
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using UnityEngine;
namespace TestNamespace
{
public class CustomComponent : MonoBehaviour
{
[SerializeField]
private string customText = "Hello from custom asmdef!";
[SerializeField]
private float customFloat = 42.0f;
void Start()
{
Debug.Log($"CustomComponent started: {customText}, value: {customFloat}");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78ee39b9744834fe390a4c7c5634eb5a
@@ -0,0 +1,14 @@
{
"name": "TestAsmdef",
"rootNamespace": "TestNamespace",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 72f6376fa7bdc4220b11ccce20108cdc
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
using UnityEngine;
using UnityEngine.Events;
namespace TestNamespace
{
public class UnityEventTestComponent : MonoBehaviour
{
public UnityEvent onSimpleEvent;
public UnityEvent<float> onFloatEvent;
[SerializeField]
private UnityEvent _onPrivateEvent;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1bc9b92a9b0ce4f6680e9dd552b3828d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 20332651bb6f64cadb92cf3c6d68bed5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,80 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: TestMat
m_Shader: {fileID: 46, guid: 0000000000000000f000000000000000, type: 0}
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _BumpScale: 1
- _Cutoff: 0.5
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _GlossMapScale: 1
- _Glossiness: 0.5
- _GlossyReflections: 1
- _Metallic: 0
- _Mode: 0
- _OcclusionStrength: 1
- _Parallax: 0.02
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _UVSec: 0
- _ZWrite: 1
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
m_BuildTextureStacks: []
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 472dc458037934a57a8a187db5f5d033
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9f7ba5b24c9aa4f89866aca2a81cd78e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6a74c5895837a475695408d1a7373098
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 189741066b9f4be5a32fd0ef5e9e0547
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,166 @@
using System;
using System.IO;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the real TripoAdapter through FakeHttpTransport and pumps the job state machine
/// deterministically (no editor loop, no network). Import is stubbed so no real model bytes
/// are needed; the end-to-end test writes a tiny file under a throwaway Assets subfolder and
/// cleans it up.
/// </summary>
public class AssetGenJobManagerTests
{
private const string EnvVar = "MCPFORUNITY_TRIPO_API_KEY";
private const string Secret = "tsk_e2e_secret_value";
private const string TestFolder = "Assets/Generated/__assetgen_jobtest";
private FakeHttpTransport _fake;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, Secret);
_fake = new FakeHttpTransport();
AssetGenJobManager.TransportOverrideForTests = _fake;
AssetGenJobManager.PollIntervalSeconds = 0;
// Stub import: mark the asset path without touching AssetDatabase.
AssetGenJobManager.ImportOverrideForTests = (job, path) => { job.AssetPath = path; return job; };
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, null);
try
{
string abs = Path.Combine(ProjectRoot(), TestFolder);
if (Directory.Exists(abs)) Directory.Delete(abs, true);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { /* ignore */ }
}
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
private static ModelGenRequest Req() => new ModelGenRequest
{
Provider = "tripo",
Mode = "text",
Prompt = "a low-poly oak tree",
Format = "glb",
TargetSize = 1f,
Name = "jobtest",
OutputFolder = TestFolder,
};
private static void Pump(string jobId)
{
int guard = 0;
while (!AssetGenJobManager.TryAdvanceForTests(jobId) && guard++ < 50) { }
Assert.Less(guard, 50, "state machine did not reach a terminal state");
}
[Test]
public void EndToEnd_ReachesDone_WithAssetPath()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
if (spec.Url.Contains("/openapi/task/"))
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"https://cdn.example.com/model.glb\"}}}");
return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } }; // download
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Done, job.State);
Assert.IsNotNull(job.AssetPath);
StringAssert.EndsWith("jobtest.glb", job.AssetPath);
Assert.AreEqual(1f, job.Progress);
}
[Test]
public void FailedPoll_FailsJob_AndLeaksNoKey()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
return Json("{\"code\":0,\"data\":{\"status\":\"failed\",\"error\":\"out of credits\"}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
Assert.IsNotEmpty(job.Error);
string serialized = JsonConvert.SerializeObject(job);
StringAssert.DoesNotContain(Secret, serialized);
}
[Test]
public void FileSchemeDownloadUrl_Rejected_FailsJob()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
// Poll succeeds but hands back a malicious local-file URL as the model.
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"file:///etc/passwd\"}}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("http", job.Error.ToLowerInvariant());
}
[Test]
public void Cancel_BeforeRun_MarksCanceled()
{
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Assert.IsTrue(AssetGenJobManager.Cancel(job.JobId));
AssetGenJobManager.TryAdvanceForTests(job.JobId);
Assert.AreEqual(AssetGenJobState.Canceled, job.State);
}
[Test]
public void MissingKey_FailsImmediately()
{
Environment.SetEnvironmentVariable(EnvVar, null); // remove the env key
// Ensure no stored key resolves for tripo by routing through an empty temp store.
string dir = Path.Combine(Path.GetTempPath(), "mcp_jobmgr_nokey_" + Guid.NewGuid().ToString("N"));
MCPForUnity.Editor.Security.SecureKeyStore.OverrideForTests(new MCPForUnity.Editor.Security.EncryptedFileKeyStore(dir));
try
{
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("No API key", job.Error);
}
finally
{
MCPForUnity.Editor.Security.SecureKeyStore.ResetForTests();
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 532c8eaebae24a2985bdd0d498296b35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,83 @@
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Phase 0 scaffold: non-secret AssetGen preferences round-trip through EditorPrefs
/// with sensible defaults. (API keys are NOT covered here — they live in the secure store.)
/// </summary>
public class AssetGenPrefsTests
{
private static readonly string[] Keys =
{
EditorPrefKeys.AssetGenSelectedModelProvider,
EditorPrefKeys.AssetGenSelectedImageProvider,
EditorPrefKeys.AssetGenDefaultFormat,
EditorPrefKeys.AssetGenOutputRoot,
EditorPrefKeys.AssetGenAutoNormalize,
EditorPrefKeys.AssetGenProviderEnabledPrefix + "tripo",
};
private void Clear()
{
foreach (string k in Keys) EditorPrefs.DeleteKey(k);
}
[SetUp]
public void SetUp() => Clear();
[TearDown]
public void TearDown() => Clear();
[Test]
public void Defaults_AreSensible_WhenUnset()
{
Assert.AreEqual("tripo", AssetGenPrefs.ModelProvider);
Assert.AreEqual("fal", AssetGenPrefs.ImageProvider);
Assert.AreEqual("Assets/Generated", AssetGenPrefs.OutputRoot);
Assert.IsTrue(AssetGenPrefs.AutoNormalize);
Assert.IsFalse(AssetGenPrefs.IsProviderEnabled("tripo"));
}
[Test]
public void RoundTrip_PersistsValues()
{
AssetGenPrefs.ModelProvider = "meshy";
AssetGenPrefs.OutputRoot = "Assets/Foo";
AssetGenPrefs.AutoNormalize = false;
AssetGenPrefs.SetProviderEnabled("tripo", true);
Assert.AreEqual("meshy", AssetGenPrefs.ModelProvider);
Assert.AreEqual("Assets/Foo", AssetGenPrefs.OutputRoot);
Assert.IsFalse(AssetGenPrefs.AutoNormalize);
Assert.IsTrue(AssetGenPrefs.IsProviderEnabled("tripo"));
}
[Test]
public void OutputRoot_FallsBackToDefault_WhenClearedToEmpty()
{
AssetGenPrefs.OutputRoot = "Assets/Foo";
AssetGenPrefs.OutputRoot = "";
Assert.AreEqual("Assets/Generated", AssetGenPrefs.OutputRoot);
}
[Test]
public void AssetPathNormalization_RejectsTraversalOutsideAssets()
{
Assert.IsFalse(AssetGenPaths.TryGetAssetsRelativePath("Assets/../ProjectSettings", out _));
}
[Test]
public void AssetPathNormalization_AcceptsAbsolutePathInsideAssets()
{
string abs = System.IO.Path.Combine(Application.dataPath, "Refs/ref.png");
Assert.IsTrue(AssetGenPaths.TryGetAssetsRelativePath(abs, out string rel));
Assert.AreEqual("Assets/Refs/ref.png", rel);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ae32dac98164b77a73a380b0ebaf4f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Covers the provider factory/registry: Tripo resolves to a real adapter, unimplemented
/// providers throw, and <c>List()</c> reports a key-free <c>Configured</c> bool that honors
/// the env override (<c>MCPFORUNITY_TRIPO_API_KEY</c>) without ever exposing a key value.
/// </summary>
public class AssetGenProvidersTests
{
[Test]
public void Model_Tripo_ReturnsAdapter()
{
IModelProviderAdapter adapter = AssetGenProviders.Model("tripo");
Assert.IsNotNull(adapter);
Assert.AreEqual("tripo", adapter.Id);
}
[Test]
public void Model_Unimplemented_Throws()
{
Assert.Throws<NotSupportedException>(() => AssetGenProviders.Model("hunyuan"));
}
[Test]
public void List_IncludesTripo_ConfiguredIsBool()
{
const string envName = "MCPFORUNITY_TRIPO_API_KEY";
string original = Environment.GetEnvironmentVariable(envName);
string tempDir = Path.Combine(Path.GetTempPath(), "mcp_assetgen_providers_" + Guid.NewGuid().ToString("N"));
try
{
// Deterministic empty baseline that still consults the env-override layer,
// so this test never depends on the dev machine's real key store.
SecureKeyStore.OverrideForTests(new EnvOverlayKeyStore(new EncryptedFileKeyStore(tempDir)));
Environment.SetEnvironmentVariable(envName, null);
ProviderInfo tripo = FindTripo();
Assert.IsNotNull(tripo);
Assert.AreEqual("model", tripo.Kind);
Assert.IsFalse(tripo.Configured, "no key/env should report not configured");
Environment.SetEnvironmentVariable(envName, "tsk_env_override_value");
tripo = FindTripo();
Assert.IsTrue(tripo.Configured, "env present should flip Configured to true");
}
finally
{
Environment.SetEnvironmentVariable(envName, original);
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { /* ignore */ }
}
}
private static ProviderInfo FindTripo()
{
foreach (ProviderInfo p in AssetGenProviders.List())
{
if (p.Id == "tripo") return p;
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59685def5a534586985c4971cc7c5190
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Covers the pure detection core (path list + an exists predicate) deterministically, without
/// depending on whether the test machine actually has Blender installed.
/// </summary>
public class BlenderDetectionTests
{
[Test]
public void DetectIn_ReturnsTrue_WhenACandidateExists()
{
var candidates = new List<string> { "/x/blender", "/y/blender" };
Assert.IsTrue(BlenderDetection.DetectIn(candidates, p => p == "/y/blender"));
}
[Test]
public void DetectIn_ReturnsFalse_WhenNoCandidateExists()
{
var candidates = new List<string> { "/x/blender", "/y/blender" };
Assert.IsFalse(BlenderDetection.DetectIn(candidates, _ => false));
}
[Test]
public void DetectIn_IgnoresNullOrEmptyCandidates()
{
var candidates = new List<string> { null, "", "/real/blender" };
Assert.IsTrue(BlenderDetection.DetectIn(candidates, p => p == "/real/blender"));
}
[Test]
public void CandidatePaths_AreNonEmpty()
{
CollectionAssert.IsNotEmpty(new List<string>(BlenderDetection.CandidatePaths()));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c066ef0e1704050a8baa05f984f51b3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
using System;
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class EnvKeyOverrideTests
{
private const string Var = "MCPFORUNITY_TRIPO_API_KEY";
[TearDown]
public void TearDown() => Environment.SetEnvironmentVariable(Var, null);
[Test]
public void EnvVarName_UpperWithApiKeySuffix()
{
Assert.AreEqual("MCPFORUNITY_TRIPO_API_KEY", EnvKeyOverride.EnvVarName("tripo"));
Assert.AreEqual("MCPFORUNITY_OPENROUTER_API_KEY", EnvKeyOverride.EnvVarName("openrouter"));
}
[Test]
public void TryGet_ReturnsValue_WhenEnvSet()
{
Environment.SetEnvironmentVariable(Var, "tsk_env_value_123");
Assert.IsTrue(EnvKeyOverride.TryGet("tripo", out string v));
Assert.AreEqual("tsk_env_value_123", v);
}
[Test]
public void TryGet_ReturnsFalse_WhenEnvMissing()
{
Environment.SetEnvironmentVariable(Var, null);
Assert.IsFalse(EnvKeyOverride.TryGet("tripo", out string v));
Assert.IsNull(v);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db5c682df8fd473aae015b4e11718bd7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class FalAdapterTests
{
private const string Resp = "https://queue.fal.run/fal-ai/flux-2/requests/r1";
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
private static ImageGenRequest Req() => new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat" };
[Test]
public void Submit_PostsModelEndpoint_WithKeyHeader_ReturnsResponseUrl()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(Resp, pid);
HttpRequestSpec sent = fake.RecordedRequests[0];
Assert.AreEqual("POST", sent.Method);
StringAssert.Contains("fal-ai/flux-2", sent.Url);
Assert.IsTrue(sent.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Key ", sent.Headers["Authorization"]);
}
[Test]
public void Submit_WithDimensions_IncludesImageSize()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat", Width = 512, Height = 768 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_size", sent);
StringAssert.Contains("512", sent);
StringAssert.Contains("768", sent);
}
[Test]
public void Submit_ImageMode_UsesEditEndpoint_WithImageUrlsArray()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "make it night", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/edit", rec.Url);
string body = System.Text.Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("image_urls", body);
StringAssert.Contains("https://ex.com/in.png", body);
}
[Test]
public void Poll_Completed_FetchesResult_ReturnsImageUrl()
{
var fake = new FakeHttpTransport
{
Handler = spec =>
{
if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}");
return Json("{\"images\":[{\"url\":\"https://cdn.example.com/img.png\"}]}");
}
};
var adapter = new FalAdapter();
ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
Assert.AreEqual("https://cdn.example.com/img.png", pr.DownloadUrl);
}
[Test]
public void Submit_ImageMode_OmitsImageSize_EvenWithDimensions()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "edit", ImageUrl = "https://ex.com/in.png", Width = 512, Height = 512 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string body = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.DoesNotContain("image_size", body); // /edit derives size from the source image
}
[Test]
public void Submit_ImageMode_FallbackResponseUrl_UsesBaseModelPath()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"request_id\":\"r1\"}") }; // no response_url
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "edit", ImageUrl = "https://ex.com/in.png" };
string pid = adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("/fal-ai/flux-2/requests/r1", pid);
StringAssert.DoesNotContain("/edit/requests", pid);
}
[Test]
public void Poll_InProgress_ReturnsRunning()
{
var fake = new FakeHttpTransport { Handler = spec => Json("{\"status\":\"IN_PROGRESS\"}") };
var adapter = new FalAdapter();
ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, pr.State);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f27682f34bb041f69ce0495155ccdc73
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,177 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class GenerateImageTests
{
private const string TestFolder = "Assets/Generated/__assetgen_imgtest";
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null);
Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_imghandler_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport();
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { }
try
{
string dp = Application.dataPath.Replace('\\', '/');
string abs = Path.Combine(dp.Substring(0, dp.Length - "Assets".Length), TestFolder);
if (Directory.Exists(abs)) Directory.Delete(abs, true);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(GenerateImage.HandleCommand(p)));
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
[Test]
public void Generate_WithKey_ReturnsPendingJobId()
{
_store.Set("fal", "falkey");
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
Assert.IsFalse(string.IsNullOrEmpty((string)gen["data"]["job_id"]));
}
[Test]
public void Generate_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
_store.Set("fal", "falkey");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_PathOutsideAssets_ReturnsError()
{
_store.Set("fal", "falkey");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = tmp, ["prompt"] = "edit it" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Assets", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_ImageMode_ProjectLocalPath_Accepted_ReturnsPending()
{
_store.Set("fal", "falkey");
string rel = WriteProjectFile(TestFolder + "/ref.png", new byte[] { 137, 80, 78, 71 });
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = rel, ["prompt"] = "edit it" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
[Test]
public void Generate_ImageMode_UnsupportedExtension_ReturnsError()
{
_store.Set("fal", "falkey");
string rel = WriteProjectFile(TestFolder + "/bad.tga", new byte[] { 0, 0, 2 });
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = rel, ["prompt"] = "x" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Unsupported", (string)resp["error"]);
}
[Test]
public void ListProviders_ImageOnly()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("fal", s);
StringAssert.Contains("openrouter", s);
StringAssert.DoesNotContain("tripo", s); // model providers excluded
}
[Test]
public void Generate_UnknownProvider_ReturnsError()
{
_store.Set("bogus", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "bogus", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void OpenRouterInline_EndToEnd_ReachesDone()
{
_store.Set("openrouter", "orkey");
byte[] png = { 137, 80, 78, 71, 13, 10, 26, 10 }; // PNG magic; bytes only need to be written, not validated
string b64 = Convert.ToBase64String(png);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
{
Handler = spec => new HttpResult
{
Status = 200,
IsSuccess = true,
Text = "{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + b64 + "\"}}]}}]}"
}
};
AssetGenJobManager.PollIntervalSeconds = 0;
AssetGenJobManager.ImportOverrideForTests = (job, path) => { job.AssetPath = path; return job; };
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat", Name = "imgtest", OutputFolder = TestFolder };
AssetGenJob job = AssetGenJobManager.StartImageGeneration(req);
int guard = 0;
while (!AssetGenJobManager.TryAdvanceForTests(job.JobId) && guard++ < 50) { }
Assert.Less(guard, 50);
Assert.AreEqual(AssetGenJobState.Done, job.State);
StringAssert.EndsWith("imgtest.png", job.AssetPath);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dfab9b71ed52491d9cb539269c3f1670
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,165 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class GenerateModelHandlerTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_TRIPO_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_handler_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport();
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { }
try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_model_handler"), true); } catch { }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(GenerateModel.HandleCommand(p)));
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
[Test]
public void Generate_WithKey_ReturnsPendingJobId_AndStatusReportsState()
{
_store.Set("tripo", "handlerkey123");
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
string jobId = (string)gen["data"]["job_id"];
Assert.IsFalse(string.IsNullOrEmpty(jobId));
JObject st = Call(new JObject { ["action"] = "status", ["job_id"] = jobId });
Assert.AreEqual("pending", (string)st["_mcp_status"]);
Assert.IsNotNull(st["data"]["state"]);
}
[Test]
public void Generate_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_UnimplementedProvider_ReturnsError()
{
_store.Set("hunyuan", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "hunyuan", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
// Use a provider that supports local images (Meshy) so we exercise the file-existence
// check; Tripo short-circuits on any local path (covered separately).
_store.Set("meshy", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_PathOutsideAssets_ReturnsError()
{
_store.Set("meshy", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Assets", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_ImageMode_ProjectLocalPath_Accepted_ReturnsPending()
{
_store.Set("meshy", "k");
string rel = WriteProjectFile("Assets/Generated/__assetgen_model_handler/ref.png", new byte[] { 137, 80, 78, 71 });
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = rel });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
[Test]
public void Generate_ImageMode_Tripo_LocalPath_ReturnsErrorSynchronously()
{
_store.Set("tripo", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_tripoimg_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual(false, (bool)resp["success"]); // synchronous error, not a fake 'pending'
StringAssert.Contains("Tripo", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_TextMode_RequiresPrompt()
{
_store.Set("tripo", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("prompt", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void ListProviders_IncludesTripoModel()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("tripo", s);
StringAssert.Contains("model", s);
}
[Test]
public void UnknownAction_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "frobnicate" });
Assert.AreEqual(false, (bool)resp["success"]);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7db2353f825a4ab7af5df4b6393309df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,110 @@
using System;
using System.IO;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the import_model_file handler with on-disk fixture files (no network, no provider).
/// Covers missing source, unsupported extension, and a real OBJ import that yields an asset GUID.
/// </summary>
public class ImportModelFileHandlerTests
{
private string _tempDir;
private const string TestFolder = "Assets/__import_model_file_test";
[SetUp]
public void SetUp()
{
_tempDir = Path.Combine(Path.GetTempPath(), "mcp_imf_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
[TearDown]
public void TearDown()
{
if (AssetDatabase.IsValidFolder(TestFolder))
AssetDatabase.DeleteAsset(TestFolder);
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { /* ignore */ }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(ImportModelFile.HandleCommand(p)));
private string WriteCubeObj()
{
string path = Path.Combine(_tempDir, "cube.obj");
File.WriteAllText(path,
"o Cube\n" +
"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n" +
"f 1 2 3 4\nf 5 6 7 8\nf 1 2 6 5\nf 2 3 7 6\nf 3 4 8 7\nf 4 1 5 8\n");
return path;
}
[Test]
public void MissingSource_ReturnsError()
{
JObject resp = Call(new JObject { ["sourcePath"] = Path.Combine(_tempDir, "nope.obj") });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void UnsupportedExtension_ReturnsError()
{
string txt = Path.Combine(_tempDir, "readme.txt");
File.WriteAllText(txt, "hi");
JObject resp = Call(new JObject { ["sourcePath"] = txt });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("unsupported", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void ImportsObj_ReturnsAssetPathAndGuid()
{
string obj = WriteCubeObj();
JObject resp = Call(new JObject
{
["sourcePath"] = obj,
["name"] = "TestCube",
["outputFolder"] = TestFolder,
});
Assert.AreEqual(true, (bool)resp["success"], resp.ToString());
string assetPath = (string)resp["data"]["asset_path"];
StringAssert.StartsWith(TestFolder, assetPath);
Assert.IsFalse(string.IsNullOrEmpty((string)resp["data"]["asset_guid"]));
Assert.IsTrue(File.Exists(assetPath), "imported file should exist under Assets");
}
[Test]
public void OutputFolderTraversal_ReturnsError_AndDoesNotStageOutsideAssets()
{
string obj = WriteCubeObj();
string escapeFolder = "Library/__import_model_file_escape_" + Guid.NewGuid().ToString("N");
string escapedAbs = Path.Combine(Path.GetDirectoryName(Application.dataPath), escapeFolder, "Escaped.obj");
try
{
JObject resp = Call(new JObject
{
["sourcePath"] = obj,
["name"] = "Escaped",
["outputFolder"] = "Assets/../" + escapeFolder,
});
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("output_folder", ((string)resp["error"]).ToLowerInvariant());
Assert.IsFalse(File.Exists(escapedAbs), "handler must not stage files outside Assets");
}
finally
{
string dir = Path.GetDirectoryName(escapedAbs);
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 17fa9724d8324cbf8cc1c7f4c73e020f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,119 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the import_model handler with a fake transport and a temp key store (no network).
/// Covers search (key present/absent), import (returns a pending job_id), and the
/// marketplace-only list_providers shape.
/// </summary>
public class ImportModelHandlerTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_SKETCHFAB_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_import_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
ImportModel.TransportOverrideForTests = null;
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
ImportModel.TransportOverrideForTests = null;
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { /* ignore */ }
}
private static HttpResult Json(string body)
=> new HttpResult { Status = 200, IsSuccess = true, Text = body, Body = Encoding.UTF8.GetBytes(body) };
// HandleCommand is async; the fake transport completes synchronously so awaiting here
// never blocks on the editor loop (unlike the live UnityWebRequest path).
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(ImportModel.HandleCommand(p).GetAwaiter().GetResult()));
[Test]
public void HandleCommand_IsAsync()
{
// Regression: search/preview hit UnityWebRequest, whose completion is pumped by the
// editor loop. A synchronous handler that blocks on .GetResult() deadlocks the main
// thread and freezes the editor. The handler must be async so the request can finish
// on a later tick. See the import_model editor-freeze investigation.
var ret = typeof(ImportModel)
.GetMethod(nameof(ImportModel.HandleCommand))
.ReturnType;
Assert.IsTrue(typeof(Task).IsAssignableFrom(ret),
"ImportModel.HandleCommand must return Task (async) to avoid blocking the Unity main thread.");
}
[Test]
public void Search_WithKey_ReturnsResults()
{
_store.Set("sketchfab", "sfkey123");
ImportModel.TransportOverrideForTests = new FakeHttpTransport
{
Handler = _ => Json("{\"results\":[{\"uid\":\"u1\",\"name\":\"Castle\"}]}")
};
JObject resp = Call(new JObject { ["action"] = "search", ["query"] = "castle" });
Assert.AreEqual(true, (bool)resp["success"]);
StringAssert.Contains("u1", resp.ToString());
}
[Test]
public void Search_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "search", ["query"] = "castle" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Import_WithKey_ReturnsPendingJobId()
{
_store.Set("sketchfab", "sfkey123");
// The job manager resolves the download URL through its own transport seam.
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
{
Handler = _ => Json("{\"gltf\":{\"url\":\"https://dl.sketchfab.com/u1/file.zip\"}}")
};
JObject resp = Call(new JObject { ["action"] = "import", ["uid"] = "u1", ["name"] = "castle" });
Assert.AreEqual("pending", (string)resp["_mcp_status"]);
string jobId = (string)resp["data"]["job_id"];
Assert.IsFalse(string.IsNullOrEmpty(jobId));
}
[Test]
public void ListProviders_IncludesSketchfabOnly()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("sketchfab", s);
StringAssert.Contains("marketplace", s);
StringAssert.DoesNotContain("tripo", s);
StringAssert.DoesNotContain("\"fal\"", s);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 52eb341abadd457dacc294f4624307c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,210 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Meshy adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, Bearer scheme), the task id field, and
/// poll-state mapping. The bearer secret is never asserted beyond the "Bearer " prefix.
/// </summary>
public class MeshyAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTextEndpoint_WithBearerHeader_AndReturnsResultId()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"task_meshy_1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a brass lantern", Format = "glb" };
string taskId = adapter.SubmitAsync(req, "msy_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("task_meshy_1", taskId);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/openapi/v2/text-to-3d", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a brass lantern", body);
StringAssert.Contains("preview", body);
}
[Test]
public void Poll_Succeeded_ReturnsGlbUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"status\":\"SUCCEEDED\",\"progress\":100," +
"\"model_urls\":{\"glb\":\"https://assets.meshy.ai/model.glb\",\"fbx\":\"https://assets.meshy.ai/model.fbx\"}}")
};
var adapter = new MeshyAdapter();
// Texture=false -> single-phase (no refine), so a SUCCEEDED preview surfaces directly.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Format = "glb", Texture = false };
adapter.SubmitAsync(req, "k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://assets.meshy.ai/model.glb", res.DownloadUrl);
Assert.AreEqual(1f, res.Progress, 0.001f);
}
[Test]
public void Submit_NoTaskIdInResponse_ErrorIncludesBody()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"message\":\"quota exceeded\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false };
var ex = Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult());
StringAssert.Contains("quota exceeded", ex.Message);
}
[Test]
public void Poll_InProgress_ReturnsRunning_WithProgress()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\",\"progress\":37}") };
var adapter = new MeshyAdapter();
// Submit single-phase (Texture=false) so progress is reported raw (not split across refine).
adapter.SubmitAsync(
new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false },
"k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.37f, res.Progress, 0.001f);
}
[Test]
public void Submit_ImageMode_PollsV1ImageEndpoint()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"img1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/i.glb\"}}")
};
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImageUrl = "https://ex.com/ref.png", Format = "glb" };
string id = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("img1", id);
StringAssert.Contains("/openapi/v1/image-to-3d", submitFake.RecordedRequests[0].Url);
ProviderPollResult res = adapter.PollAsync(id, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
// Image tasks must be polled at the v1 image endpoint, not the v2 text endpoint.
StringAssert.Contains("/openapi/v1/image-to-3d/img1", pollFake.RecordedRequests[0].Url);
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://m/i.glb", res.DownloadUrl);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_meshy_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImagePath = rel, Format = "glb" };
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/openapi/v1/image-to-3d", rec.Url);
StringAssert.Contains("data:image/png;base64,", Encoding.UTF8.GetString(rec.Body));
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_meshy_adapter"), true); } catch { } }
}
[Test]
public void TextWithTexture_PreviewSucceeded_SubmitsRefine_ThenReturnsTexturedModel()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"prev1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = spec => spec.Method == "POST"
? Json("{\"result\":\"refine1\"}") // refine submit
: Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/refined.glb\"}}")
};
var adapter = new MeshyAdapter();
// Texture defaults to true -> two-phase preview+refine.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a chair", Format = "glb" };
string previewId = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("prev1", previewId);
// Poll #1: preview SUCCEEDED -> adapter submits a refine task and reports Running.
ProviderPollResult p1 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, p1.State);
bool refinePosted = false;
foreach (HttpRequestSpec r in pollFake.RecordedRequests)
{
if (r.Method == "POST" && r.Body != null)
{
string b = Encoding.UTF8.GetString(r.Body);
if (b.Contains("refine") && b.Contains("prev1")) refinePosted = true;
}
}
Assert.IsTrue(refinePosted, "expected a refine POST carrying the preview_task_id");
// Poll #2: the refine task SUCCEEDED -> textured model url surfaced.
ProviderPollResult p2 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, p2.State);
Assert.AreEqual("https://m/refined.glb", p2.DownloadUrl);
}
[Test]
public void Poll_Failed_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"FAILED\",\"progress\":0,\"task_error\":{\"message\":\"render error\"}}")
};
var adapter = new MeshyAdapter();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 47843d3832064cf783a4ee52735f6b3e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,62 @@
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Import;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Validates the import pipeline's guard paths without fabricating model bytes. Real FBX/GLB
/// import is left to the user's licensed editor (manual verification checklist).
/// Assumes glTFast is NOT installed in the test project (it is an optional dependency).
/// </summary>
public class ModelImportPipelineTests
{
private static AssetGenJob Job(string format) => new AssetGenJob
{
JobId = "test",
Kind = "model",
Provider = "tripo",
State = AssetGenJobState.Importing,
Format = format,
TargetSize = 1f,
};
[Test]
public void Glb_WithoutGltfast_FailsWithActionableMessage()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("glb"), "Assets/Generated/Models/__fake_nonexistent.glb");
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("glTFast", result.Error);
}
[Test]
public void PathOutsideAssets_Fails()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("fbx"), "/tmp/somewhere/model.fbx");
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("Assets", result.Error);
}
[Test]
public void NullPath_Fails()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("fbx"), null);
Assert.AreEqual(AssetGenJobState.Failed, result.State);
}
[TestCase("generic", ModelImporterAnimationType.Generic)]
[TestCase("Generic", ModelImporterAnimationType.Generic)]
[TestCase("humanoid", ModelImporterAnimationType.Human)]
[TestCase("human", ModelImporterAnimationType.Human)]
[TestCase(" LEGACY ", ModelImporterAnimationType.Legacy)]
[TestCase("none", ModelImporterAnimationType.None)]
[TestCase("", ModelImporterAnimationType.None)]
[TestCase(null, ModelImporterAnimationType.None)]
[TestCase("nonsense", ModelImporterAnimationType.None)]
public void ParseAnimationType_MapsRigMode(string input, ModelImporterAnimationType expected)
{
Assert.AreEqual(expected, ModelImportPipeline.ParseAnimationType(input));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 68e84150afa841cfa8e0a7329037859c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class OpenRouterAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
[Test]
public void Submit_Then_Poll_ReturnsInlineImageBytes()
{
byte[] expected = { 1, 2, 3, 4 };
string b64 = Convert.ToBase64String(expected);
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + b64 + "\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat" };
string pid = adapter.SubmitAsync(req, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("ready", pid);
// Submit posts to chat/completions with a Bearer key.
HttpRequestSpec sent = fake.RecordedRequests[0];
Assert.AreEqual("POST", sent.Method);
StringAssert.Contains("chat/completions", sent.Url);
StringAssert.StartsWith("Bearer ", sent.Headers["Authorization"]);
ProviderPollResult pr = adapter.PollAsync(pid, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
CollectionAssert.AreEqual(expected, pr.InlineData);
}
[Test]
public void Submit_ImageMode_IncludesReferenceImageInBody()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "make it watercolor", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("https://ex.com/in.png", sent);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_openrouter_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "watercolor", ImagePath = rel };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("data:image/png;base64,", sent);
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_openrouter_adapter"), true); } catch { } }
}
[Test]
public void Submit_NoImage_PollFails()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"content\":\"sorry, no image\"}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat" };
adapter.SubmitAsync(req, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
ProviderPollResult pr = adapter.PollAsync("ready", "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, pr.State);
Assert.IsNotEmpty(pr.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dc263c722db149f6979a08d4e7e376ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,114 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Import;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Verifies <see cref="SafeZipExtractor"/> unpacks normal entries and rejects Zip-Slip
/// traversal entries. Builds tiny zips in memory (no fixtures, no network).
/// </summary>
public class SafeZipExtractorTests
{
private string _work;
[SetUp]
public void SetUp()
{
_work = Path.Combine(Path.GetTempPath(), "mcp_zip_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_work);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch { /* ignore */ }
}
private string MakeZip(string entryName, string content)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
ZipArchiveEntry entry = archive.CreateEntry(entryName);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
private string MakeMultiZip(params (string name, string content)[] entries)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var (name, content) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(name);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
[Test]
public void Allowlist_SkipsDisallowedEntries()
{
// A hostile marketplace archive: a valid model plus an editor script + a managed dll.
string zip = MakeMultiZip(
("teapot.obj", "o teapot"),
("Editor/Hack.cs", "// [InitializeOnLoad] arbitrary code"),
("plugins/Evil.dll", "MZ..."));
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest, new System.Collections.Generic.HashSet<string> { ".obj" });
Assert.IsTrue(File.Exists(Path.Combine(dest, "teapot.obj")), "allowed model must be written");
Assert.IsFalse(File.Exists(Path.Combine(dest, "Editor", "Hack.cs")), "disallowed .cs must be skipped");
Assert.IsFalse(File.Exists(Path.Combine(dest, "plugins", "Evil.dll")), "disallowed .dll must be skipped");
}
[Test]
public void NormalEntry_Extracts()
{
string zip = MakeZip("models/teapot.obj", "o teapot");
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest);
string extracted = Path.Combine(dest, "models", "teapot.obj");
Assert.IsTrue(File.Exists(extracted), "expected extracted file to exist");
Assert.AreEqual("o teapot", File.ReadAllText(extracted));
}
[Test]
public void TraversalEntry_Throws()
{
string zip = MakeZip("../evil.txt", "pwned");
string dest = Path.Combine(_work, "out");
Assert.Throws<IOException>(() => SafeZipExtractor.ExtractTo(zip, dest));
string escaped = Path.GetFullPath(Path.Combine(_work, "evil.txt"));
Assert.IsFalse(File.Exists(escaped), "traversal target must not be written");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0808d9aabe914ea29b323640caf3f998
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class SecretRedactorTests
{
[Test]
public void Scrub_RedactsBearerToken()
{
string s = SecretRedactor.Scrub("Authorization: Bearer tsk_abc123XYZ");
StringAssert.DoesNotContain("tsk_abc123XYZ", s);
StringAssert.Contains("REDACTED", s);
}
[Test]
public void Scrub_RedactsKeyAndTokenSchemes()
{
StringAssert.DoesNotContain("falkey123456", SecretRedactor.Scrub("Authorization: Key falkey123456"));
StringAssert.DoesNotContain("sktoken987654", SecretRedactor.Scrub("Authorization: Token sktoken987654"));
}
[Test]
public void Scrub_WithExplicitSecret_RedactsThatValue()
{
string s = SecretRedactor.Scrub("error: key sk-supersecret rejected", "sk-supersecret");
StringAssert.DoesNotContain("sk-supersecret", s);
}
[Test]
public void Scrub_LeavesNormalTextAlone()
{
Assert.AreEqual("hello world", SecretRedactor.Scrub("hello world"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ed1cc6ea39d48dcaad4fbbf372c979c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,92 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Targets the CI-safe EncryptedFileKeyStore directly against a throwaway temp dir
/// (never the user's real store). Proves round-trip, deletion, overwrite, multi-secret
/// blobs, and that the on-disk file is actually encrypted.
/// </summary>
public class SecureKeyStoreTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
_dir = Path.Combine(Path.GetTempPath(), "mcp_assetgen_keys_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { /* ignore */ }
}
[Test]
public void SetThenGet_RoundTrips()
{
_store.Set("tripo", "tsk_secret_value_123");
Assert.IsTrue(_store.Has("tripo"));
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual("tsk_secret_value_123", v);
}
[Test]
public void Get_Missing_ReturnsFalse()
{
Assert.IsFalse(_store.Has("meshy"));
Assert.IsFalse(_store.TryGet("meshy", out string v));
Assert.IsNull(v);
}
[Test]
public void Delete_RemovesKey()
{
_store.Set("fal", "k-123456");
_store.Delete("fal");
Assert.IsFalse(_store.Has("fal"));
Assert.IsFalse(_store.TryGet("fal", out _));
}
[Test]
public void Overwrite_ReplacesValue()
{
_store.Set("tripo", "first_value");
_store.Set("tripo", "second_value");
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual("second_value", v);
}
[Test]
public void MultiSecret_JsonBlob_RoundTrips()
{
string blob = "{\"secretId\":\"AKID123\",\"secretKey\":\"SK456\"}";
_store.Set("tripo", blob);
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual(blob, v);
}
[Test]
public void Set_Empty_DeletesKey()
{
_store.Set("tripo", "abc");
_store.Set("tripo", "");
Assert.IsFalse(_store.Has("tripo"));
}
[Test]
public void Ciphertext_OnDisk_DoesNotContainPlaintext()
{
_store.Set("tripo", "PLAINTEXT_SECRET_XYZ");
string file = Path.Combine(_dir, "key_tripo.bin");
Assert.IsTrue(File.Exists(file));
StringAssert.DoesNotContain("PLAINTEXT_SECRET_XYZ", File.ReadAllText(file));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa8115bc72ce42a9aadd51bf567135df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Sketchfab marketplace adapter against a <see cref="FakeHttpTransport"/>.
/// Asserts the "Token" auth scheme and that the download endpoint's gltf.url is parsed into
/// the archive URL the job manager will fetch. The secret is never asserted beyond the scheme.
/// </summary>
public class SketchfabAdapterTests
{
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Search_GetsSearchEndpoint_WithTokenHeader_ReturnsRawJson()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[{\"uid\":\"abc\"}]}") };
var adapter = new SketchfabAdapter();
string raw = adapter.SearchAsync("castle", null, true, null, null, "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("\"uid\":\"abc\"", raw);
HttpRequestSpec rec = http.RecordedRequests[0];
Assert.AreEqual("GET", rec.Method);
StringAssert.Contains("/v3/search", rec.Url);
StringAssert.Contains("downloadable=true", rec.Url);
StringAssert.Contains("q=castle", rec.Url);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void Search_ForwardsCategoriesCountCursorAndDownloadableFlag()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[],\"cursors\":{\"next\":\"2\"}}") };
var adapter = new SketchfabAdapter();
adapter.SearchAsync("castle", "architecture", false, 12, "2", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
string url = http.RecordedRequests[0].Url;
StringAssert.Contains("categories=architecture", url);
StringAssert.Contains("count=12", url);
StringAssert.Contains("cursor=2", url);
StringAssert.Contains("downloadable=false", url);
}
[Test]
public void ResolveDownloadUrl_ParsesGltfUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"gltf\":{\"url\":\"https://dl.sketchfab.com/models/abc/file.zip?sig=x\"}}")
};
var adapter = new SketchfabAdapter();
string url = adapter.ResolveDownloadUrlAsync("abc", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("https://dl.sketchfab.com/models/abc/file.zip?sig=x", url);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/v3/models/abc/download", rec.Url);
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void ResolveDownloadUrl_MissingGltf_Throws()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"usdz\":{\"url\":\"https://x/y.usdz\"}}") };
var adapter = new SketchfabAdapter();
Assert.Throws<System.Exception>(() =>
adapter.ResolveDownloadUrlAsync("abc", "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e0da413b01b2437390f8f7b7b9d20c44
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,136 @@
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Tripo adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, auth scheme, body) and poll-state mapping.
/// The bearer secret is never asserted beyond the "Bearer " scheme prefix.
/// </summary>
public class TripoAdapterTests
{
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTaskEndpoint_WithBearerHeader_AndReturnsTaskId()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"abc123\"}}")
};
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "text", Prompt = "a red dragon" };
string taskId = adapter.SubmitAsync(req, "tsk_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("abc123", taskId);
Assert.AreEqual(1, http.RecordedRequests.Count);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/v2/openapi/task", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a red dragon", body);
StringAssert.Contains("text_to_model", body);
}
[Test]
public void Poll_MapsSuccess_WithDownloadUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100," +
"\"output\":{\"pbr_model\":\"https://cdn.tripo3d.ai/model.glb\"}}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "tsk_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://cdn.tripo3d.ai/model.glb", res.DownloadUrl);
}
[Test]
public void Poll_MapsRunning_WithProgress()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"status\":\"running\",\"progress\":42}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "k", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.42f, res.Progress, 0.001f);
}
[Test]
public void Poll_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"status\":\"failed\",\"progress\":0}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "k", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
[Test]
public void Submit_ImageMode_UsesImageToModel()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"img1\"}}")
};
var adapter = new TripoAdapter();
var req = new ModelGenRequest
{
Provider = "tripo",
Mode = "image",
ImageUrl = "https://example.com/in.png"
};
adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult();
string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body);
StringAssert.Contains("image_to_model", body);
StringAssert.Contains("https://example.com/in.png", body);
}
[Test]
public void Submit_ImageMode_LocalPathOnly_Throws()
{
// Tripo can't take a local image inline (no data-URI support, upload not wired) — it must
// fail clearly rather than silently falling back to text mode.
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "image", ImagePath = "/tmp/whatever.png" };
Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", new FakeHttpTransport(), CancellationToken.None).GetAwaiter().GetResult());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79b6a1e9658e417d960212be835c0c84
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 57176dc46eea4bbba356c3959cfeb33f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,76 @@
using System;
using System.IO;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Models;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Clients
{
/// <summary>
/// Covers the regression where JsonFileMcpConfigurator.CheckStatus only recognized
/// the "url" property and missed "serverUrl" (Antigravity/Windsurf) and "httpUrl"
/// (Gemini CLI), so clients configured via HTTP looked unconfigured and got rewritten
/// every startup by the auto-rewrite path.
/// </summary>
[TestFixture]
public class CheckStatusUrlPropertyTests
{
private string _tempDir;
[SetUp]
public void SetUp()
{
_tempDir = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { }
}
[Test]
public void CheckStatus_DetectsHttp_WhenUrlProperty()
=> AssertHttpDetected("url");
[Test]
public void CheckStatus_DetectsHttp_WhenServerUrlProperty()
=> AssertHttpDetected("serverUrl");
[Test]
public void CheckStatus_DetectsHttp_WhenHttpUrlProperty()
=> AssertHttpDetected("httpUrl");
private void AssertHttpDetected(string urlProperty)
{
string configPath = Path.Combine(_tempDir, $"{urlProperty}.json");
File.WriteAllText(configPath,
"{\"mcpServers\":{\"unityMCP\":{\"" + urlProperty + "\":\"http://localhost:65535/mcp\"}}}");
var client = new McpClient
{
name = "Fake",
windowsConfigPath = configPath,
macConfigPath = configPath,
linuxConfigPath = configPath,
HttpUrlProperty = urlProperty,
};
var configurator = new FakeJsonConfigurator(client);
configurator.CheckStatus(attemptAutoRewrite: false);
Assert.AreNotEqual(ConfiguredTransport.Unknown, client.configuredTransport,
$"CheckStatus must recognize the '{urlProperty}' property as an HTTP URL");
Assert.That(
client.configuredTransport == ConfiguredTransport.Http
|| client.configuredTransport == ConfiguredTransport.HttpRemote,
$"Expected HTTP/HttpRemote transport for '{urlProperty}', got {client.configuredTransport}");
}
private sealed class FakeJsonConfigurator : JsonFileMcpConfigurator
{
public FakeJsonConfigurator(McpClient client) : base(client) { }
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9b5bc8d0b36d477e8239edff10911bba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
using System.IO;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Clients.Configurators;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Clients
{
[TestFixture]
public class IsInstalledTests
{
[Test]
public void IMcpClientConfigurator_ExposesIsInstalled()
{
var prop = typeof(IMcpClientConfigurator).GetProperty("IsInstalled");
Assert.IsNotNull(prop, "IMcpClientConfigurator must expose an IsInstalled property");
Assert.AreEqual(typeof(bool), prop.PropertyType);
}
[Test]
public void JsonClient_NotInstalled_WhenParentDirMissing()
{
var cursor = new CursorConfigurator();
string parent = Path.GetDirectoryName(cursor.GetConfigPath());
if (parent == null || !Directory.Exists(parent))
{
Assert.IsFalse(cursor.IsInstalled,
"Cursor parent dir does not exist on this machine, IsInstalled must be false");
}
else
{
Assert.IsTrue(cursor.IsInstalled,
"Cursor parent dir exists, IsInstalled must be true");
}
}
[Test]
public void JsonClient_Installed_WhenParentDirExists()
{
var claude = new ClaudeDesktopConfigurator();
string parent = Path.GetDirectoryName(claude.GetConfigPath());
bool expected = parent != null && Directory.Exists(parent);
Assert.AreEqual(expected, claude.IsInstalled);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 15e00d05358545a5955aa1c291653289
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System.Linq;
using MCPForUnity.Editor.Clients;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Models;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Clients
{
[TestFixture]
public class SupportedTransportsTests
{
[Test]
public void IMcpClientConfigurator_ExposesSupportedTransports()
{
var prop = typeof(IMcpClientConfigurator).GetProperty("SupportedTransports");
Assert.IsNotNull(prop, "Must expose SupportedTransports");
}
[Test]
public void ClaudeDesktop_SupportsStdioOnly()
{
var claude = new ClaudeDesktopConfigurator();
CollectionAssert.Contains(claude.SupportedTransports.ToList(), ConfiguredTransport.Stdio);
CollectionAssert.DoesNotContain(claude.SupportedTransports.ToList(), ConfiguredTransport.Http);
}
[Test]
public void Cursor_SupportsBothTransports()
{
var cursor = new CursorConfigurator();
var list = cursor.SupportedTransports.ToList();
CollectionAssert.Contains(list, ConfiguredTransport.Stdio);
CollectionAssert.Contains(list, ConfiguredTransport.Http);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 26823824aa5cae0caa9488e9ad49bcf1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d539787bf8f6a426e94bfffb32a36d4f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Helpers
{
public class AssetPathUtilityOfflineTests
{
private bool _originalForceRefresh;
[SetUp]
public void SetUp()
{
_originalForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
}
[TearDown]
public void TearDown()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalForceRefresh);
}
[Test]
public void ShouldUseUvxOffline_WhenForceRefreshEnabled_ReturnsFalse()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, true);
Assert.IsFalse(AssetPathUtility.ShouldUseUvxOffline());
}
[Test]
public void ShouldUseUvxOffline_DoesNotThrow()
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
Assert.DoesNotThrow(() => AssetPathUtility.ShouldUseUvxOffline());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 39ea6f0fc573340d689bf01ef5510153
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,156 @@
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Clients.Configurators;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using EditorConfigCache = MCPForUnity.Editor.Services.EditorConfigurationCache;
namespace MCPForUnityTests.Editor.Helpers
{
// Per-client MCP config-format coverage.
//
// Issue #1120: Kilo Code v7.0.33+ moved its MCP config from the VS Code extension's
// globalStorage/mcp_settings.json (mcpServers / type:"http" or "streamableHttp" / disabled)
// to a CLI-style kilo.jsonc under ~/.config/kilo whose schema (https://app.kilo.ai/config.json)
// uses an "mcp" container, type:"remote" for HTTP servers, and an "enabled" flag. Writing the
// old format left the server showing as "stdio" + disabled. These tests pin the new Kilo format
// while guarding that Cline keeps "streamableHttp" and generic clients keep plain "http".
public class ClientConfigFormatTests
{
private const string UseHttpTransportPrefKey = EditorPrefKeys.UseHttpTransport;
private bool _hadHttpTransport;
private bool _originalHttpTransport;
[SetUp]
public void SetUp()
{
_hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey);
_originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
// Force HTTP transport so the remote/streamableHttp branch is exercised.
EditorPrefs.SetBool(UseHttpTransportPrefKey, true);
EditorConfigCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
if (_hadHttpTransport)
EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport);
else
EditorPrefs.DeleteKey(UseHttpTransportPrefKey);
EditorConfigCache.Instance.Refresh();
}
[Test]
public void KiloCodeConfigurator_DeclaresNewKiloFormat()
{
var client = new KiloCodeConfigurator().Client;
Assert.AreEqual("mcp", client.ServerContainerKey,
"Kilo Code's new schema nests servers under an \"mcp\" container, not \"mcpServers\"");
Assert.AreEqual("remote", client.HttpTypeValue,
"Kilo Code's new schema uses type:\"remote\" for HTTP servers");
Assert.AreEqual("local", client.StdioTypeValue,
"Kilo Code's new schema uses type:\"local\" for stdio servers");
Assert.AreEqual("https://app.kilo.ai/config.json", client.SchemaUrl,
"Kilo Code config should declare the kilo.jsonc $schema");
Assert.IsTrue(client.DefaultUnityFields.ContainsKey("enabled"),
"Kilo Code uses an \"enabled\" flag instead of \"disabled\"");
Assert.AreEqual(true, client.DefaultUnityFields["enabled"],
"Kilo Code must default enabled:true so the server is active");
Assert.IsFalse(client.DefaultUnityFields.ContainsKey("disabled"),
"Kilo Code's new schema must not write the legacy \"disabled\" field");
foreach (var path in new[] { client.windowsConfigPath, client.macConfigPath, client.linuxConfigPath })
{
Assert.AreEqual("kilo.jsonc", System.IO.Path.GetFileName(path),
"Kilo Code config must target kilo.jsonc, not mcp_settings.json");
}
}
[Test]
public void BuildManualConfigJson_ForKiloCode_UsesMcpContainerRemoteTypeAndEnabled()
{
var client = new KiloCodeConfigurator().Client;
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
Assert.AreEqual("https://app.kilo.ai/config.json", (string)root["$schema"],
"Kilo Code config should include the kilo.jsonc $schema at the root");
Assert.IsNull(root["mcpServers"], "Kilo Code must not use the legacy \"mcpServers\" container");
var unity = (JObject)root.SelectToken("mcp.unityMCP");
Assert.NotNull(unity, "Expected mcp.unityMCP node");
Assert.AreEqual("remote", (string)unity["type"],
"Kilo Code HTTP config must use type:remote, not type:http/streamableHttp");
Assert.AreEqual(true, (bool)unity["enabled"],
"Kilo Code config must set enabled:true");
Assert.IsNull(unity["disabled"], "Kilo Code must not write the legacy disabled field");
Assert.IsNotNull(unity["url"], "HTTP transport should set a url");
Assert.IsNull(unity["command"], "HTTP transport should not include a command");
}
[Test]
public void ApplyUnityServerToExistingConfig_ForKiloCode_RewritesStaleStdioToRemote()
{
var client = new KiloCodeConfigurator().Client;
// Simulate a stale local (stdio) entry that Kilo would have shown as the wrong transport.
var root = new JObject
{
["mcp"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = "uvx",
["args"] = new JArray("unity-mcp-server"),
["type"] = "local"
}
}
};
var result = ConfigJsonBuilder.ApplyUnityServerToExistingConfig(root, uvPath: null, client);
Assert.AreEqual("https://app.kilo.ai/config.json", (string)result["$schema"],
"Rewrite should add the kilo.jsonc $schema when missing");
var unity = (JObject)result.SelectToken("mcp.unityMCP");
Assert.NotNull(unity, "Expected mcp.unityMCP node");
Assert.AreEqual("remote", (string)unity["type"],
"Existing config should be rewritten to type:remote for Kilo Code");
Assert.AreEqual(true, (bool)unity["enabled"], "Existing config should gain enabled:true");
Assert.IsNull(unity["command"], "HTTP transport should remove the stale stdio command");
}
[Test]
public void BuildManualConfigJson_ForCline_StillUsesStreamableHttpInMcpServers()
{
var client = new ClineConfigurator().Client;
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
Assert.IsNull(root["$schema"], "Cline must not write a $schema");
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("streamableHttp", (string)unity["type"],
"Cline must keep type:streamableHttp after the name-check was replaced with a flag");
}
[Test]
public void BuildManualConfigJson_ForGenericHttpClient_UsesPlainHttpType()
{
// A client without a type override must continue to receive the generic type:http.
var client = new McpClient { name = "Cursor" };
var root = JObject.Parse(ConfigJsonBuilder.BuildManualConfigJson(uvPath: null, client));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("http", (string)unity["type"],
"Clients without HttpTypeValue should keep the generic type:http");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3e8c1a47f2d4e9b8a6c5d0f1e2a3b4c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,595 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.External.Tommy;
using MCPForUnity.Editor.Services;
using System.IO;
using MCPForUnity.Editor.Constants;
using UnityEditor;
namespace MCPForUnityTests.Editor.Helpers
{
public class CodexConfigHelperTests
{
/// <summary>
/// Validates that a TOML args array contains the expected uvx structure:
/// --from, a mcpforunityserver reference, mcp-for-unity package name,
/// and optionally --prerelease/explicit (only for prerelease builds).
/// </summary>
private static void AssertValidUvxArgs(TomlArray args)
{
var argValues = new List<string>();
foreach (TomlNode child in args.Children)
argValues.Add((child as TomlString).Value);
Assert.IsTrue(argValues.Contains("--from"), "Args should contain --from");
Assert.IsTrue(argValues.Any(a => a.Contains("mcpforunityserver")), "Args should contain PyPI package reference");
Assert.IsTrue(argValues.Contains("mcp-for-unity"), "Args should contain package name");
// Prerelease builds include --prerelease explicit before --from
int fromIndex = argValues.IndexOf("--from");
int prereleaseIndex = argValues.IndexOf("--prerelease");
if (prereleaseIndex >= 0)
{
Assert.IsTrue(prereleaseIndex < fromIndex, "--prerelease should come before --from");
Assert.AreEqual("explicit", argValues[prereleaseIndex + 1], "--prerelease should be followed by explicit");
}
}
/// <summary>
/// Mock platform service for testing
/// </summary>
private class MockPlatformService : IPlatformService
{
private readonly bool _isWindows;
private readonly string _systemRoot;
public MockPlatformService(bool isWindows, string systemRoot = "C:\\Windows")
{
_isWindows = isWindows;
_systemRoot = systemRoot;
}
public bool IsWindows() => _isWindows;
public string GetSystemRoot() => _isWindows ? _systemRoot : null;
}
private bool _hadGitOverride;
private string _originalGitOverride;
private bool _hadHttpTransport;
private bool _originalHttpTransport;
private bool _hadDevForceRefresh;
private bool _originalDevForceRefresh;
private IPlatformService _originalPlatformService;
[OneTimeSetUp]
public void OneTimeSetUp()
{
_hadGitOverride = EditorPrefs.HasKey(EditorPrefKeys.GitUrlOverride);
_originalGitOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
_hadHttpTransport = EditorPrefs.HasKey(EditorPrefKeys.UseHttpTransport);
_originalHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_hadDevForceRefresh = EditorPrefs.HasKey(EditorPrefKeys.DevModeForceServerRefresh);
_originalDevForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
_originalPlatformService = MCPServiceLocator.Platform;
}
[SetUp]
public void SetUp()
{
// Ensure per-test deterministic Git URL (ignore developer overrides)
EditorPrefs.DeleteKey(EditorPrefKeys.GitUrlOverride);
// Default to stdio mode for existing tests unless specified otherwise
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Ensure deterministic uvx args ordering for these tests regardless of editor settings
// (dev-mode inserts --no-cache/--refresh, which changes the first args).
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
// Refresh the cache so it picks up the test's pref values
EditorConfigurationCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// IMPORTANT:
// These tests can be executed while an MCP session is active (e.g., when running tests via MCP).
// MCPServiceLocator.Reset() disposes the bridge + transport manager, which can kill the MCP connection
// mid-run. Instead, restore only what this fixture mutates.
// To avoid leaking global state to other tests/fixtures, restore the original platform service
// instance captured before this fixture started running.
if (_originalPlatformService != null)
{
MCPServiceLocator.Register<IPlatformService>(_originalPlatformService);
}
else
{
MCPServiceLocator.Register<IPlatformService>(new PlatformService());
}
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
if (_hadGitOverride)
{
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, _originalGitOverride);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.GitUrlOverride);
}
if (_hadHttpTransport)
{
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _originalHttpTransport);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.UseHttpTransport);
}
if (_hadDevForceRefresh)
{
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, _originalDevForceRefresh);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.DevModeForceServerRefresh);
}
}
[Test]
public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server\"",
"args = [\"mcp-for-unity\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should detect server definition");
Assert.AreEqual("uvx --from git+https://github.com/CoplayDev/unity-mcp@v6.3.0#subdirectory=Server", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx\"",
"args = [",
" \"mcp-for-unity\",",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uvx\"",
"args = [",
" \"mcp-for-unity\", # package name",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should tolerate comments within the array block");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_HeaderWithComment_StillDetected()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP] # annotated header",
"command = \"uvx\"",
"args = [\"mcp-for-unity\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should recognize section headers even with inline comments");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = 'uvx'",
"args = ['mcp-for-unity']"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes");
Assert.AreEqual("uvx", command);
CollectionAssert.AreEqual(new[] { "mcp-for-unity" }, args);
}
[Test]
public void BuildCodexServerBlock_OnWindows_IncludesSystemRootEnv()
{
// This test verifies that Windows-specific environment configuration is included in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock Windows platform
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: true));
string uvPath = "C:\\Program Files\\uv\\uv.exe";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env.SystemRoot is present on Windows
bool hasEnv = unityMcp.TryGetNode("env", out var envNode);
Assert.IsTrue(hasEnv, "Windows config should contain env table");
Assert.IsInstanceOf<TomlTable>(envNode, "env should be a table");
var env = envNode as TomlTable;
Assert.IsTrue(env.TryGetNode("SystemRoot", out var systemRootNode), "env should contain SystemRoot");
Assert.IsInstanceOf<TomlString>(systemRootNode, "SystemRoot should be a string");
var systemRoot = (systemRootNode as TomlString).Value;
Assert.AreEqual("C:\\Windows", systemRoot, "SystemRoot should be C:\\Windows");
}
[Test]
public void BuildCodexServerBlock_OnNonWindows_ExcludesEnv()
{
// This test verifies that non-Windows platforms don't include env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock non-Windows platform (e.g., macOS/Linux)
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: false));
string uvPath = "/usr/local/bin/uv";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env is NOT present on non-Windows platforms
bool hasEnv = unityMcp.TryGetNode("env", out _);
Assert.IsFalse(hasEnv, "Non-Windows config should not contain env table");
}
[Test]
public void UpsertCodexServerBlock_OnWindows_IncludesSystemRootEnv()
{
// This test verifies the fix for https://github.com/CoplayDev/unity-mcp/issues/315
// Ensures that upsert operations also include Windows-specific env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock Windows platform
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: true, systemRoot: "C:\\Windows"));
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "C:\\path\\to\\uv.exe";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env.SystemRoot is present on Windows
bool hasEnv = unityMcp.TryGetNode("env", out var envNode);
Assert.IsTrue(hasEnv, "Windows config should contain env table");
Assert.IsInstanceOf<TomlTable>(envNode, "env should be a table");
var env = envNode as TomlTable;
Assert.IsTrue(env.TryGetNode("SystemRoot", out var systemRootNode), "env should contain SystemRoot");
Assert.IsInstanceOf<TomlString>(systemRootNode, "SystemRoot should be a string");
var systemRoot = (systemRootNode as TomlString).Value;
Assert.AreEqual("C:\\Windows", systemRoot, "SystemRoot should be C:\\Windows");
}
[Test]
public void UpsertCodexServerBlock_OnNonWindows_ExcludesEnv()
{
// This test verifies that upsert operations on non-Windows platforms don't include env configuration in stdio mode
// Force stdio mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
// Mock non-Windows platform (e.g., macOS/Linux)
MCPServiceLocator.Register<IPlatformService>(new MockPlatformService(isWindows: false));
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "/usr/local/bin/uv";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
Assert.IsTrue(unityMcp.TryGetNode("command", out var commandNode), "unityMCP should contain command");
Assert.IsTrue(unityMcp.TryGetNode("args", out var argsNode), "unityMCP should contain args");
// Verify command contains uvx
var command = (commandNode as TomlString).Value;
Assert.IsTrue(command.Contains("uvx"), "Command should contain uvx");
// Verify args contains the proper uvx command structure
var args = argsNode as TomlArray;
AssertValidUvxArgs(args);
// Verify env is NOT present on non-Windows platforms
bool hasEnv = unityMcp.TryGetNode("env", out _);
Assert.IsFalse(hasEnv, "Non-Windows config should not contain env table");
}
[Test]
public void BuildCodexServerBlock_HttpMode_GeneratesUrlField()
{
// This test verifies HTTP transport mode generates url field instead of command/args
// Force HTTP mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
string uvPath = "C:\\Program Files\\uv\\uv.exe";
string result = CodexConfigHelper.BuildCodexServerBlock(uvPath);
Assert.IsNotNull(result, "BuildCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify basic structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
// Verify features.rmcp_client is enabled for HTTP transport
Assert.IsTrue(parsed.TryGetNode("features", out var featuresNode), "HTTP mode should include features table");
Assert.IsInstanceOf<TomlTable>(featuresNode, "features should be a table");
var features = featuresNode as TomlTable;
Assert.IsTrue(features.TryGetNode("rmcp_client", out var rmcpNode), "features should include rmcp_client flag");
Assert.IsInstanceOf<TomlBoolean>(rmcpNode, "rmcp_client should be a boolean");
Assert.IsTrue((rmcpNode as TomlBoolean).Value, "rmcp_client should be true");
// Verify url field is present
Assert.IsTrue(unityMcp.TryGetNode("url", out var urlNode), "unityMCP should contain url in HTTP mode");
Assert.IsInstanceOf<TomlString>(urlNode, "url should be a string");
var url = (urlNode as TomlString).Value;
Assert.IsTrue(url.Contains("http"), "URL should be an HTTP endpoint");
Assert.IsTrue(url.Contains("/mcp"), "URL should contain /mcp path");
// Verify command and args are NOT present in HTTP mode
Assert.IsFalse(unityMcp.TryGetNode("command", out _), "HTTP mode should not contain command field");
Assert.IsFalse(unityMcp.TryGetNode("args", out _), "HTTP mode should not contain args field");
Assert.IsFalse(unityMcp.TryGetNode("env", out _), "HTTP mode should not contain env field");
}
[Test]
public void TryParseCodexServer_HttpMode_ParsesUrlSuccessfully()
{
// This test verifies HTTP mode parsing with url field
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"url = \"http://localhost:8080/mcp/v1/rpc\""
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args, out string url);
Assert.IsTrue(result, "Parser should accept HTTP mode with url field");
Assert.IsNull(command, "Command should be null in HTTP mode");
Assert.IsNull(args, "Args should be null in HTTP mode");
Assert.AreEqual("http://localhost:8080/mcp/v1/rpc", url, "URL should be parsed correctly");
}
[Test]
public void UpsertCodexServerBlock_HttpMode_GeneratesUrlField()
{
// This test verifies HTTP mode upsert generates url field
// Force HTTP mode
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
string existingToml = string.Join("\n", new[]
{
"[other_section]",
"key = \"value\""
});
string uvPath = "C:\\path\\to\\uv.exe";
string result = CodexConfigHelper.UpsertCodexServerBlock(existingToml, uvPath);
Assert.IsNotNull(result, "UpsertCodexServerBlock should return a valid TOML string");
// Parse the generated TOML to validate structure
TomlTable parsed;
using (var reader = new StringReader(result))
{
parsed = TOML.Parse(reader);
}
// Verify existing sections are preserved
Assert.IsTrue(parsed.TryGetNode("other_section", out _), "TOML should preserve existing sections");
// Verify mcp_servers structure
Assert.IsTrue(parsed.TryGetNode("mcp_servers", out var mcpServersNode), "TOML should contain mcp_servers");
Assert.IsInstanceOf<TomlTable>(mcpServersNode, "mcp_servers should be a table");
var mcpServers = mcpServersNode as TomlTable;
Assert.IsTrue(mcpServers.TryGetNode("unityMCP", out var unityMcpNode), "mcp_servers should contain unityMCP");
Assert.IsInstanceOf<TomlTable>(unityMcpNode, "unityMCP should be a table");
var unityMcp = unityMcpNode as TomlTable;
// Verify features.rmcp_client is enabled for HTTP transport
Assert.IsTrue(parsed.TryGetNode("features", out var featuresNode), "HTTP mode should include features table");
Assert.IsInstanceOf<TomlTable>(featuresNode, "features should be a table");
var features = featuresNode as TomlTable;
Assert.IsTrue(features.TryGetNode("rmcp_client", out var rmcpNode), "features should include rmcp_client flag");
Assert.IsInstanceOf<TomlBoolean>(rmcpNode, "rmcp_client should be a boolean");
Assert.IsTrue((rmcpNode as TomlBoolean).Value, "rmcp_client should be true");
// Verify url field is present
Assert.IsTrue(unityMcp.TryGetNode("url", out var urlNode), "unityMCP should contain url in HTTP mode");
Assert.IsInstanceOf<TomlString>(urlNode, "url should be a string");
var url = (urlNode as TomlString).Value;
Assert.IsTrue(url.Contains("http"), "URL should be an HTTP endpoint");
// Verify command and args are NOT present in HTTP mode
Assert.IsFalse(unityMcp.TryGetNode("command", out _), "HTTP mode should not contain command field");
Assert.IsFalse(unityMcp.TryGetNode("args", out _), "HTTP mode should not contain args field");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 013424dea29744a98b3dc01618f4e95e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,134 @@
using System.Linq;
using MCPForUnity.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for Matrix4x4Converter to ensure it safely serializes matrices
/// without accessing dangerous computed properties (lossyScale, rotation).
/// Regression test for https://github.com/CoplayDev/unity-mcp/issues/478
/// </summary>
public class Matrix4x4ConverterTests
{
private JsonSerializerSettings _settings;
[SetUp]
public void SetUp()
{
_settings = new JsonSerializerSettings
{
Converters = { new Matrix4x4Converter() }
};
}
[Test]
public void Serialize_IdentityMatrix_ReturnsCorrectJson()
{
var matrix = Matrix4x4.identity;
var json = JsonConvert.SerializeObject(matrix, _settings);
Assert.That(json, Does.Contain("\"m00\":1"));
Assert.That(json, Does.Contain("\"m11\":1"));
Assert.That(json, Does.Contain("\"m22\":1"));
Assert.That(json, Does.Contain("\"m33\":1"));
Assert.That(json, Does.Contain("\"m01\":0"));
}
[Test]
public void Deserialize_IdentityMatrix_ReturnsIdentity()
{
var original = Matrix4x4.identity;
var json = JsonConvert.SerializeObject(original, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
Assert.That(result, Is.EqualTo(original));
}
[Test]
public void Serialize_TranslationMatrix_PreservesValues()
{
var matrix = Matrix4x4.Translate(new Vector3(10, 20, 30));
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
Assert.That(result.m03, Is.EqualTo(10f));
Assert.That(result.m13, Is.EqualTo(20f));
Assert.That(result.m23, Is.EqualTo(30f));
}
[Test]
public void Serialize_DegenerateMatrix_DoesNotCrashAndRoundtrips()
{
// This is the key test - a degenerate matrix that would crash
// if we accessed lossyScale or rotation properties
var matrix = new Matrix4x4();
matrix.m00 = 0; matrix.m11 = 0; matrix.m22 = 0; // Degenerate - determinant = 0
// This should NOT throw or crash - the old code would fail here
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
// Verify JSON only contains raw mXY properties
var jo = JObject.Parse(json);
var expectedProps = new[]
{
"m00", "m01", "m02", "m03",
"m10", "m11", "m12", "m13",
"m20", "m21", "m22", "m23",
"m30", "m31", "m32", "m33"
};
CollectionAssert.AreEquivalent(expectedProps, jo.Properties().Select(p => p.Name).ToArray());
// Verify values roundtrip correctly (all zeros for degenerate matrix)
Assert.That(result.m00, Is.EqualTo(0f));
Assert.That(result.m11, Is.EqualTo(0f));
Assert.That(result.m22, Is.EqualTo(0f));
Assert.That(result, Is.EqualTo(matrix));
}
[Test]
public void Serialize_NonTRSMatrix_DoesNotCrash()
{
// Projection matrices are NOT valid TRS matrices
// Accessing lossyScale/rotation on them causes ValidTRS() assertion
var matrix = Matrix4x4.Perspective(60f, 1.77f, 0.1f, 1000f);
// Verify it's not a valid TRS matrix
Assert.That(matrix.ValidTRS(), Is.False, "Test requires non-TRS matrix");
// This should NOT throw - the fix ensures we never access computed properties
Assert.DoesNotThrow(() =>
{
var json = JsonConvert.SerializeObject(matrix, _settings);
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
});
}
[Test]
public void Deserialize_NullToken_ReturnsZeroMatrix()
{
var json = "null";
var result = JsonConvert.DeserializeObject<Matrix4x4>(json, _settings);
// Returns zero matrix (consistent with missing field defaults of 0f)
Assert.That(result, Is.EqualTo(new Matrix4x4()));
}
[Test]
public void Serialize_DoesNotContainDangerousProperties()
{
var matrix = Matrix4x4.TRS(Vector3.one, Quaternion.identity, Vector3.one);
var json = JsonConvert.SerializeObject(matrix, _settings);
// Ensure we're not serializing the dangerous computed properties
Assert.That(json, Does.Not.Contain("lossyScale"));
Assert.That(json, Does.Not.Contain("rotation"));
Assert.That(json, Does.Not.Contain("inverse"));
Assert.That(json, Does.Not.Contain("transpose"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d2c5dd202b4944675ae606581676a24a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,209 @@
using System.Collections.Generic;
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for the standard Pagination classes.
/// </summary>
public class PaginationTests
{
#region PaginationRequest Tests
[Test]
public void PaginationRequest_FromParams_ParsesPageSizeSnakeCase()
{
var p = new JObject { ["page_size"] = 25 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(25, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_ParsesPageSizeCamelCase()
{
var p = new JObject { ["pageSize"] = 30 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(30, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_ParsesCursor()
{
var p = new JObject { ["cursor"] = 50 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(50, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_ConvertsPageNumberToCursor()
{
// page_number is 1-based, should convert to 0-based cursor
var p = new JObject { ["page_number"] = 3, ["page_size"] = 10 };
var req = PaginationRequest.FromParams(p);
// Page 3 with page size 10 means items 20-29, so cursor should be 20
Assert.AreEqual(20, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_CursorTakesPrecedenceOverPageNumber()
{
// If both cursor and page_number are specified, cursor should win
var p = new JObject { ["cursor"] = 100, ["page_number"] = 1 };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(100, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_UsesDefaultsForNullParams()
{
var req = PaginationRequest.FromParams(null);
Assert.AreEqual(50, req.PageSize);
Assert.AreEqual(0, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_UsesDefaultsForEmptyParams()
{
var req = PaginationRequest.FromParams(new JObject());
Assert.AreEqual(50, req.PageSize);
Assert.AreEqual(0, req.Cursor);
}
[Test]
public void PaginationRequest_FromParams_AcceptsCustomDefaultPageSize()
{
var req = PaginationRequest.FromParams(new JObject(), defaultPageSize: 100);
Assert.AreEqual(100, req.PageSize);
}
[Test]
public void PaginationRequest_FromParams_HandleStringValues()
{
// Some clients might send string values
var p = new JObject { ["page_size"] = "15", ["cursor"] = "5" };
var req = PaginationRequest.FromParams(p);
Assert.AreEqual(15, req.PageSize);
Assert.AreEqual(5, req.Cursor);
}
#endregion
#region PaginationResponse Tests
[Test]
public void PaginationResponse_Create_ReturnsCorrectPageOfItems()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(3, response.Items.Count);
Assert.AreEqual(new List<int> { 1, 2, 3 }, response.Items);
}
[Test]
public void PaginationResponse_Create_ReturnsCorrectMiddlePage()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(3, response.Items.Count);
Assert.AreEqual(new List<int> { 4, 5, 6 }, response.Items);
}
[Test]
public void PaginationResponse_Create_HandlesLastPage()
{
var allItems = new List<int> { 1, 2, 3, 4, 5 };
var request = new PaginationRequest { PageSize = 3, Cursor = 3 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(2, response.Items.Count);
Assert.AreEqual(new List<int> { 4, 5 }, response.Items);
Assert.IsNull(response.NextCursor);
Assert.IsFalse(response.HasMore);
}
[Test]
public void PaginationResponse_HasMore_TrueWhenNextCursorSet()
{
var allItems = new List<int> { 1, 2, 3, 4, 5, 6 };
var request = new PaginationRequest { PageSize = 3, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.IsTrue(response.HasMore);
Assert.AreEqual(3, response.NextCursor);
}
[Test]
public void PaginationResponse_HasMore_FalseWhenNoMoreItems()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.IsFalse(response.HasMore);
Assert.IsNull(response.NextCursor);
}
[Test]
public void PaginationResponse_Create_SetsCorrectTotalCount()
{
var allItems = new List<string> { "a", "b", "c", "d", "e" };
var request = new PaginationRequest { PageSize = 2, Cursor = 0 };
var response = PaginationResponse<string>.Create(allItems, request);
Assert.AreEqual(5, response.TotalCount);
}
[Test]
public void PaginationResponse_Create_HandlesEmptyList()
{
var allItems = new List<int>();
var request = new PaginationRequest { PageSize = 10, Cursor = 0 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Items.Count);
Assert.AreEqual(0, response.TotalCount);
Assert.IsNull(response.NextCursor);
Assert.IsFalse(response.HasMore);
}
[Test]
public void PaginationResponse_Create_ClampsCursorToValidRange()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = 100 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Items.Count);
Assert.AreEqual(3, response.Cursor); // Clamped to totalCount
}
[Test]
public void PaginationResponse_Create_HandlesNegativeCursor()
{
var allItems = new List<int> { 1, 2, 3 };
var request = new PaginationRequest { PageSize = 10, Cursor = -5 };
var response = PaginationResponse<int>.Create(allItems, request);
Assert.AreEqual(0, response.Cursor); // Clamped to 0
Assert.AreEqual(3, response.Items.Count);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a6d0177f4432b41c6bf7e0013cd5a2f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,469 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
/// <summary>
/// Tests for the ToolParams parameter validation wrapper.
/// </summary>
public class ToolParamsTests
{
#region Constructor Tests
[Test]
public void ToolParams_Constructor_ThrowsOnNullParams()
{
Assert.Throws<System.ArgumentNullException>(() => new ToolParams(null));
}
[Test]
public void ToolParams_Constructor_AcceptsEmptyJObject()
{
Assert.DoesNotThrow(() => new ToolParams(new JObject()));
}
#endregion
#region GetRequired Tests
[Test]
public void GetRequired_ExistingParameter_ReturnsSuccess()
{
var json = new JObject { ["action"] = "create" };
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual("create", result.Value);
Assert.IsNull(result.ErrorMessage);
}
[Test]
public void GetRequired_MissingParameter_ReturnsError()
{
var json = new JObject();
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.That(result.ErrorMessage, Does.Contain("'action' parameter is required"));
}
[Test]
public void GetRequired_EmptyStringParameter_ReturnsError()
{
var json = new JObject { ["action"] = "" };
var p = new ToolParams(json);
var result = p.GetRequired("action");
Assert.IsFalse(result.IsSuccess);
Assert.That(result.ErrorMessage, Does.Contain("'action' parameter is required"));
}
[Test]
public void GetRequired_CustomErrorMessage_ReturnsCustomError()
{
var json = new JObject();
var p = new ToolParams(json);
var result = p.GetRequired("action", "Custom error message");
Assert.IsFalse(result.IsSuccess);
Assert.AreEqual("Custom error message", result.ErrorMessage);
}
#endregion
#region Get Tests
[Test]
public void Get_ExistingParameter_ReturnsValue()
{
var json = new JObject { ["name"] = "TestObject" };
var p = new ToolParams(json);
var value = p.Get("name");
Assert.AreEqual("TestObject", value);
}
[Test]
public void Get_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.Get("name");
Assert.IsNull(value);
}
[Test]
public void Get_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.Get("name", "DefaultName");
Assert.AreEqual("DefaultName", value);
}
#endregion
#region Snake/Camel Case Fallback Tests
[Test]
public void Get_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["search_method"] = "by_name" };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var value = p.Get("searchMethod");
Assert.AreEqual("by_name", value);
}
[Test]
public void Get_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["searchMethod"] = "by_name" };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var value = p.Get("search_method");
Assert.AreEqual("by_name", value);
}
[Test]
public void Get_ExactMatchTakesPrecedence()
{
// If both snake_case and camelCase exist, exact match wins
var json = new JObject
{
["search_method"] = "snake",
["searchMethod"] = "camel"
};
var p = new ToolParams(json);
Assert.AreEqual("snake", p.Get("search_method"));
Assert.AreEqual("camel", p.Get("searchMethod"));
}
#endregion
#region GetInt Tests
[Test]
public void GetInt_ValidInteger_ReturnsValue()
{
var json = new JObject { ["count"] = "10" };
var p = new ToolParams(json);
var value = p.GetInt("count");
Assert.AreEqual(10, value);
}
[Test]
public void GetInt_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetInt("count");
Assert.IsNull(value);
}
[Test]
public void GetInt_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetInt("count", 5);
Assert.AreEqual(5, value);
}
[Test]
public void GetInt_InvalidInteger_ReturnsDefault()
{
var json = new JObject { ["count"] = "not_a_number" };
var p = new ToolParams(json);
var value = p.GetInt("count", 5);
Assert.AreEqual(5, value);
}
#endregion
#region GetFloat Tests
[Test]
public void GetFloat_ValidFloat_ReturnsValue()
{
var json = new JObject { ["scale"] = "2.5" };
var p = new ToolParams(json);
var value = p.GetFloat("scale");
Assert.AreEqual(2.5f, value);
}
[Test]
public void GetFloat_MissingParameter_ReturnsNull()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetFloat("scale");
Assert.IsNull(value);
}
[Test]
public void GetFloat_MissingParameterWithDefault_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetFloat("scale", 1.0f);
Assert.AreEqual(1.0f, value);
}
#endregion
#region GetBool Tests
[Test]
public void GetBool_TrueBoolean_ReturnsTrue()
{
var json = new JObject { ["enabled"] = true };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsTrue(value);
}
[Test]
public void GetBool_FalseBoolean_ReturnsFalse()
{
var json = new JObject { ["enabled"] = false };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsFalse(value);
}
[Test]
public void GetBool_MissingParameter_ReturnsDefault()
{
var json = new JObject();
var p = new ToolParams(json);
var value = p.GetBool("enabled", true);
Assert.IsTrue(value);
}
[Test]
public void GetBool_StringTrue_ReturnsTrue()
{
var json = new JObject { ["enabled"] = "true" };
var p = new ToolParams(json);
var value = p.GetBool("enabled");
Assert.IsTrue(value);
}
[Test]
public void GetBool_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["include_inactive"] = true };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var value = p.GetBool("includeInactive");
Assert.IsTrue(value);
}
[Test]
public void GetBool_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["includeInactive"] = true };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var value = p.GetBool("include_inactive");
Assert.IsTrue(value);
}
#endregion
#region Has Tests
[Test]
public void Has_ExistingParameter_ReturnsTrue()
{
var json = new JObject { ["key"] = "value" };
var p = new ToolParams(json);
Assert.IsTrue(p.Has("key"));
}
[Test]
public void Has_MissingParameter_ReturnsFalse()
{
var json = new JObject();
var p = new ToolParams(json);
Assert.IsFalse(p.Has("key"));
}
[Test]
public void Has_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["search_term"] = "Player" };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
Assert.IsTrue(p.Has("searchTerm"));
}
[Test]
public void Has_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["searchTerm"] = "Player" };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
Assert.IsTrue(p.Has("search_term"));
}
#endregion
#region GetRaw Tests
[Test]
public void GetRaw_ComplexObject_ReturnsJToken()
{
var json = new JObject { ["data"] = new JObject { ["nested"] = "value" } };
var p = new ToolParams(json);
var raw = p.GetRaw("data");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual("value", raw["nested"]?.ToString());
}
[Test]
public void GetRaw_Array_ReturnsJToken()
{
var json = new JObject { ["items"] = new JArray { "a", "b", "c" } };
var p = new ToolParams(json);
var raw = p.GetRaw("items");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JArray>(raw);
Assert.AreEqual(3, ((JArray)raw).Count);
}
[Test]
public void GetRaw_SnakeCaseParameter_FindsWithCamelCaseKey()
{
var json = new JObject { ["component_properties"] = new JObject { ["mass"] = 1.5 } };
var p = new ToolParams(json);
// Asking for camelCase should find snake_case
var raw = p.GetRaw("componentProperties");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual(1.5, raw["mass"]?.Value<double>());
}
[Test]
public void GetRaw_CamelCaseParameter_FindsWithSnakeCaseKey()
{
var json = new JObject { ["componentProperties"] = new JObject { ["mass"] = 1.5 } };
var p = new ToolParams(json);
// Asking for snake_case should find camelCase
var raw = p.GetRaw("component_properties");
Assert.IsNotNull(raw);
Assert.IsInstanceOf<JObject>(raw);
Assert.AreEqual(1.5, raw["mass"]?.Value<double>());
}
#endregion
#region Result<T> Tests
[Test]
public void Result_Success_IsSuccessTrue()
{
var result = Result<string>.Success("value");
Assert.IsTrue(result.IsSuccess);
Assert.AreEqual("value", result.Value);
Assert.IsNull(result.ErrorMessage);
}
[Test]
public void Result_Error_IsSuccessFalse()
{
var result = Result<string>.Error("error message");
Assert.IsFalse(result.IsSuccess);
Assert.IsNull(result.Value);
Assert.AreEqual("error message", result.ErrorMessage);
}
[Test]
public void Result_GetOrError_Success_ReturnsNull()
{
var result = Result<string>.Success("value");
var error = result.GetOrError(out var value);
Assert.IsNull(error);
Assert.AreEqual("value", value);
}
[Test]
public void Result_GetOrError_Error_ReturnsErrorResponse()
{
var result = Result<string>.Error("error message");
var error = result.GetOrError(out var value);
Assert.IsNotNull(error);
Assert.IsInstanceOf<ErrorResponse>(error);
Assert.IsNull(value);
var errorResponse = error as ErrorResponse;
Assert.AreEqual("error message", errorResponse.error);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8207a28487246e8a3c5f28481581354
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,475 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Models;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using EditorConfigCache = MCPForUnity.Editor.Services.EditorConfigurationCache;
namespace MCPForUnityTests.Editor.Helpers
{
public class WriteToConfigTests
{
private const string UseHttpTransportPrefKey = EditorPrefKeys.UseHttpTransport;
private const string HttpUrlPrefKey = EditorPrefKeys.HttpBaseUrl;
private string _tempRoot;
private string _fakeUvPath;
private string _serverSrcDir;
// Save/restore original pref values (must happen BEFORE Assert.Ignore since TearDown still runs)
private bool _hadHttpTransport;
private bool _originalHttpTransport;
private bool _hadHttpUrl;
private string _originalHttpUrl;
[SetUp]
public void SetUp()
{
// Save original pref values FIRST - TearDown runs even when test is ignored!
_hadHttpTransport = EditorPrefs.HasKey(UseHttpTransportPrefKey);
_originalHttpTransport = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
_hadHttpUrl = EditorPrefs.HasKey(HttpUrlPrefKey);
_originalHttpUrl = EditorPrefs.GetString(HttpUrlPrefKey, "");
// Tests are designed for Linux/macOS runners. Skip on Windows due to ProcessStartInfo
// restrictions when UseShellExecute=false for .cmd/.bat scripts.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Assert.Ignore("WriteToConfig tests are skipped on Windows (CI runs linux).\n" +
"ValidateUvBinarySafe requires launching an actual exe on Windows.");
}
_tempRoot = Path.Combine(Path.GetTempPath(), "UnityMCPTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
// Create a fake uv executable that prints a valid version string
_fakeUvPath = Path.Combine(_tempRoot, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "uv.cmd" : "uv");
File.WriteAllText(_fakeUvPath, "#!/bin/sh\n\necho 'uv 9.9.9'\n");
TryChmodX(_fakeUvPath);
// Create a fake server directory with server.py
_serverSrcDir = Path.Combine(_tempRoot, "server-src");
Directory.CreateDirectory(_serverSrcDir);
File.WriteAllText(Path.Combine(_serverSrcDir, "server.py"), "# dummy server\n");
// Point the editor to our server dir (so ResolveServerSrc() uses this)
EditorPrefs.SetString(EditorPrefKeys.ServerSrc, _serverSrcDir);
// Ensure no lock is enabled
EditorPrefs.SetBool(EditorPrefKeys.LockCursorConfig, false);
// Disable auto-registration to avoid hitting user configs during tests
EditorPrefs.SetBool(EditorPrefKeys.AutoRegisterEnabled, false);
// Force HTTP transport defaults so expectations match current behavior
EditorPrefs.SetBool(UseHttpTransportPrefKey, true);
EditorPrefs.SetString(HttpUrlPrefKey, "http://localhost:8080");
EditorConfigCache.Instance.Refresh();
}
[TearDown]
public void TearDown()
{
// Clean up editor preferences set during SetUp
EditorPrefs.DeleteKey(EditorPrefKeys.ServerSrc);
EditorPrefs.DeleteKey(EditorPrefKeys.LockCursorConfig);
EditorPrefs.DeleteKey(EditorPrefKeys.AutoRegisterEnabled);
// Restore original pref values (don't delete if user had them set!)
if (_hadHttpTransport)
EditorPrefs.SetBool(UseHttpTransportPrefKey, _originalHttpTransport);
else
EditorPrefs.DeleteKey(UseHttpTransportPrefKey);
if (_hadHttpUrl)
EditorPrefs.SetString(HttpUrlPrefKey, _originalHttpUrl);
else
EditorPrefs.DeleteKey(HttpUrlPrefKey);
// Remove temp files
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }
}
// --- Tests ---
[Test]
public void AddsDisabledFalseAndServerUrl_ForWindsurf()
{
var configPath = Path.Combine(_tempRoot, "windsurf.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "Windsurf configs should not include an env block");
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Windsurf when missing");
AssertTransportConfiguration(unity, client);
}
[Test]
public void AddsEnvAndDisabledFalse_ForKiro()
{
var configPath = Path.Combine(_tempRoot, "kiro.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient
{
name = "Kiro",
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.NotNull(unity["env"], "env should be present for all clients");
Assert.IsTrue(unity["env"]!.Type == JTokenType.Object, "env should be an object");
Assert.AreEqual(false, (bool)unity["disabled"], "disabled:false should be set for Kiro when missing");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForCursor()
{
var configPath = Path.Combine(_tempRoot, "cursor.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "Cursor" };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "env should not be added for non-Windsurf/Kiro clients");
Assert.IsNull(unity["disabled"], "disabled should not be added for non-Windsurf/Kiro clients");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForVSCode()
{
var configPath = Path.Combine(_tempRoot, "vscode.json");
WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "VSCode", IsVsCodeLayout = true };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("servers.unityMCP");
Assert.NotNull(unity, "Expected servers.unityMCP node");
Assert.IsNull(unity["env"], "env should not be added for VSCode client");
Assert.IsNull(unity["disabled"], "disabled should not be added for VSCode client");
AssertTransportConfiguration(unity, client);
}
[Test]
public void DoesNotAddEnvOrDisabled_ForTrae()
{
var configPath = Path.Combine(_tempRoot, "trae.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
var client = new McpClient { name = "Trae" };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "env should not be added for Trae client");
Assert.IsNull(unity["disabled"], "disabled should not be added for Trae client");
AssertTransportConfiguration(unity, client);
}
[Test]
public void ClaudeDesktop_UsesAbsoluteUvPath_WhenOverrideProvided()
{
var configPath = Path.Combine(_tempRoot, "claude-desktop.json");
WriteInitialConfig(configPath, isVSCode: false, command: "uvx", directory: "/old/path");
WithTransportPreference(false, () =>
{
MCPServiceLocator.Paths.SetUvxPathOverride(_fakeUvPath);
try
{
var client = new McpClient
{
name = "Claude Desktop",
SupportsHttpTransport = false,
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual(_fakeUvPath, (string)unity["command"], "Claude Desktop should use absolute uvx path");
Assert.IsNull(unity["env"], "Claude Desktop config should not include env block when not required");
AssertTransportConfiguration(unity, client);
}
finally
{
MCPServiceLocator.Paths.ClearUvxPathOverride();
}
});
}
[Test]
public void PreservesExistingEnvAndDisabled_ForKiro()
{
var configPath = Path.Combine(_tempRoot, "preserve.json");
// Existing config with env and disabled=true should be preserved
var json = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = _fakeUvPath,
["args"] = new JArray("run", "--directory", "/old/path", "server.py"),
["env"] = new JObject { ["FOO"] = "bar" },
["disabled"] = true
}
}
};
File.WriteAllText(configPath, json.ToString());
var client = new McpClient
{
name = "Kiro",
EnsureEnvObject = true,
DefaultUnityFields = { { "disabled", false } }
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.AreEqual("bar", (string)unity["env"]!["FOO"], "Existing env should be preserved");
Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved");
AssertTransportConfiguration(unity, client);
}
[Test]
public void RemovesEnvBlock_ForWindsurf()
{
var configPath = Path.Combine(_tempRoot, "windsurf-env.json");
var json = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = _fakeUvPath,
["args"] = new JArray("run", "--directory", "/old/path", "server.py"),
["env"] = new JObject { ["SHOULD"] = "be removed" },
["disabled"] = true
}
}
};
File.WriteAllText(configPath, json.ToString());
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
Assert.IsNull(unity["env"], "Windsurf config should strip any existing env block");
Assert.AreEqual(true, (bool)unity["disabled"], "Existing disabled value should be preserved");
AssertTransportConfiguration(unity, client);
}
[Test]
public void UsesStdioTransport_ForNonVSCodeClients_WhenPreferenceDisabled()
{
var configPath = Path.Combine(_tempRoot, "stdio-non-vscode.json");
WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path");
WithTransportPreference(false, () =>
{
var client = new McpClient
{
name = "Windsurf",
HttpUrlProperty = "serverUrl",
DefaultUnityFields = { { "disabled", false } },
StripEnvWhenNotRequired = true
};
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("mcpServers.unityMCP");
Assert.NotNull(unity, "Expected mcpServers.unityMCP node");
AssertTransportConfiguration(unity, client);
});
}
[Test]
public void UsesStdioTransport_ForVSCode_WhenPreferenceDisabled()
{
var configPath = Path.Combine(_tempRoot, "stdio-vscode.json");
WriteInitialConfig(configPath, isVSCode: true, command: _fakeUvPath, directory: "/old/path");
WithTransportPreference(false, () =>
{
var client = new McpClient { name = "VSCode", IsVsCodeLayout = true };
InvokeWriteToConfig(configPath, client);
var root = JObject.Parse(File.ReadAllText(configPath));
var unity = (JObject)root.SelectToken("servers.unityMCP");
Assert.NotNull(unity, "Expected servers.unityMCP node");
AssertTransportConfiguration(unity, client);
});
}
// --- Helpers ---
private static void TryChmodX(string path)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "/bin/chmod",
Arguments = "+x \"" + path + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using var p = Process.Start(psi);
p?.WaitForExit(2000);
}
catch { /* best-effort on non-Unix */ }
}
private static void WriteInitialConfig(string configPath, bool isVSCode, string command, string directory)
{
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
JObject root;
if (isVSCode)
{
root = new JObject
{
["servers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = command,
["args"] = new JArray("run", "--directory", directory, "server.py"),
["type"] = "stdio"
}
}
};
}
else
{
root = new JObject
{
["mcpServers"] = new JObject
{
["unityMCP"] = new JObject
{
["command"] = command,
["args"] = new JArray("run", "--directory", directory, "server.py")
}
}
};
}
File.WriteAllText(configPath, root.ToString());
}
private static void InvokeWriteToConfig(string configPath, McpClient client)
{
var result = McpConfigurationHelper.WriteMcpConfiguration(configPath, client);
Assert.AreEqual("Configured successfully", result, "WriteMcpConfiguration should return success");
}
private static void AssertTransportConfiguration(JObject unity, McpClient client)
{
bool useHttp = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
bool isWindsurf = string.Equals(client.HttpUrlProperty, "serverUrl", StringComparison.OrdinalIgnoreCase);
if (useHttp)
{
string expectedUrl = HttpEndpointUtility.GetMcpRpcUrl();
if (isWindsurf)
{
Assert.AreEqual(expectedUrl, (string)unity["serverUrl"],
"Windsurf should advertise HTTP using serverUrl");
Assert.IsNull(unity["url"], "Windsurf configs should not use the url property");
}
else
{
Assert.AreEqual(expectedUrl, (string)unity["url"],
"HTTP transport should set url to the MCP endpoint");
Assert.IsNull(unity["serverUrl"], "serverUrl should be reserved for Windsurf");
}
Assert.IsNull(unity["command"], "HTTP transport should remove command");
Assert.IsNull(unity["args"], "HTTP transport should remove args");
// "type" is now included for all clients (standard MCP protocol field).
Assert.AreEqual("http", (string)unity["type"],
"All entries should advertise HTTP transport type");
}
else
{
Assert.IsNull(unity["url"], "stdio transport should not include a url");
Assert.IsNull(unity["serverUrl"], "stdio transport should not include a serverUrl");
string command = (string)unity["command"];
Assert.False(string.IsNullOrEmpty(command), "stdio transport should include a command");
var args = (unity["args"] as JArray)?.ToObject<string[]>();
Assert.NotNull(args, "stdio transport should include args array");
int transportIndex = Array.IndexOf(args, "--transport");
Assert.GreaterOrEqual(transportIndex, 0, "args should include --transport flag");
Assert.Less(transportIndex + 1, args.Length,
"--transport flag should be followed by a mode value");
Assert.AreEqual("stdio", args[transportIndex + 1],
"--transport should be followed by stdio mode");
// "type" is now included for all clients (standard MCP protocol field).
Assert.AreEqual("stdio", (string)unity["type"],
"All entries should advertise stdio transport type");
}
}
private static void WithTransportPreference(bool useHttp, Action action)
{
bool original = EditorPrefs.GetBool(UseHttpTransportPrefKey, true);
EditorPrefs.SetBool(UseHttpTransportPrefKey, useHttp);
EditorConfigCache.Instance.Refresh();
try
{
action();
}
finally
{
EditorPrefs.SetBool(UseHttpTransportPrefKey, original);
EditorConfigCache.Instance.Refresh();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3fc4210e7cbef4479b2cb9498b1580a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
{
"name": "MCPForUnityTests.EditMode",
"rootNamespace": "",
"references": [
"MCPForUnity.Editor",
"MCPForUnity.Runtime",
"TestAsmdef",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll",
"Newtonsoft.Json.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 32956d76430ba4ea4a794f28ee983d16
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using System;
using System.IO;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor
{
/// <summary>
/// Locks the strict override contract of Claude CLI resolution after discovery was
/// consolidated into ExecPath.ResolveClaude(): a valid override is honored, and an
/// override that points at a missing file returns null rather than falling back to
/// auto-discovery.
/// </summary>
public class PathResolverClaudeCliTests
{
private const string Key = EditorPrefKeys.ClaudeCliPathOverride;
private bool _hadOverride;
private string _savedOverride;
[SetUp]
public void SetUp()
{
_savedOverride = EditorPrefs.GetString(Key, string.Empty);
_hadOverride = !string.IsNullOrEmpty(_savedOverride);
}
[TearDown]
public void TearDown()
{
if (_hadOverride) EditorPrefs.SetString(Key, _savedOverride);
else EditorPrefs.DeleteKey(Key);
}
[Test]
public void GetClaudeCliPath_ValidOverride_ReturnsOverride()
{
string stub = Path.Combine(Path.GetTempPath(), "mcp_claude_stub_" + Guid.NewGuid().ToString("N"));
File.WriteAllText(stub, "stub");
try
{
EditorPrefs.SetString(Key, stub);
var resolver = new PathResolverService();
Assert.AreEqual(stub, resolver.GetClaudeCliPath());
Assert.IsTrue(resolver.IsClaudeCliDetected());
}
finally
{
File.Delete(stub);
}
}
[Test]
public void GetClaudeCliPath_InvalidOverride_ReturnsNull_WithoutDiscoveryFallback()
{
string missing = Path.Combine(Path.GetTempPath(), "mcp_claude_missing_" + Guid.NewGuid().ToString("N"));
EditorPrefs.SetString(Key, missing);
var resolver = new PathResolverService();
Assert.IsNull(resolver.GetClaudeCliPath());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 762a862b7c1c4f3b9526dbff1c7e153a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 552680d3640c64564b19677d789515c3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using NUnit.Framework;
using Newtonsoft.Json.Linq;
using MCPForUnity.Editor.Resources.MenuItems;
using System;
using System.Linq;
namespace MCPForUnityTests.Editor.Resources.MenuItems
{
public class GetMenuItemsTests
{
private static JObject ToJO(object o) => JObject.FromObject(o);
[Test]
public void NoSearch_ReturnsSuccessAndArray()
{
var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false });
var jo = ToJO(res);
Assert.IsTrue((bool)jo["success"], "Expected success true");
Assert.IsNotNull(jo["data"], "Expected data field present");
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
// Validate list is sorted ascending when there are multiple items
var arr = (JArray)jo["data"];
if (arr.Count >= 2)
{
var original = arr.Select(t => (string)t).ToList();
var sorted = original.OrderBy(s => s, StringComparer.Ordinal).ToList();
CollectionAssert.AreEqual(sorted, original, "Expected menu items to be sorted ascending");
}
}
[Test]
public void SearchNoMatch_ReturnsEmpty()
{
var res = GetMenuItems.HandleCommand(new JObject { ["search"] = "___unlikely___term___" });
var jo = ToJO(res);
Assert.IsTrue((bool)jo["success"], "Expected success true");
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
Assert.AreEqual(0, jo["data"].Count(), "Expected no results for unlikely search term");
}
[Test]
public void SearchMatchesExistingItem_ReturnsContainingItem()
{
// Get the full list first
var listRes = GetMenuItems.HandleCommand(new JObject { ["search"] = "", ["refresh"] = false });
var listJo = ToJO(listRes);
if (listJo["data"] is JArray arr && arr.Count > 0)
{
var first = (string)arr[0];
// Use a mid-substring (case-insensitive) to avoid edge cases
var term = first.Length > 4 ? first.Substring(1, Math.Min(3, first.Length - 2)) : first;
term = term.ToLowerInvariant();
var res = GetMenuItems.HandleCommand(new JObject { ["search"] = term, ["refresh"] = false });
var jo = ToJO(res);
Assert.IsTrue((bool)jo["success"], "Expected success true");
Assert.AreEqual(JTokenType.Array, jo["data"].Type, "Expected data to be an array");
// Expect at least the original item to be present
var names = ((JArray)jo["data"]).Select(t => (string)t).ToList();
CollectionAssert.Contains(names, first, "Expected search results to include the sampled item");
}
else
{
Assert.Pass("No menu items available to perform a content-based search assertion.");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dbae8d670978f4a2bb525d7da9ed9f34
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a7b66499ec8924852a539d5cc4378c0d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7b2c3d4e5f6a7890123456789abcdef3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,899 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using MCPForUnity.Editor.Services.Server;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using UnityEngine.TestTools;
namespace MCPForUnityTests.Editor.Services.Characterization
{
/// <summary>
/// Characterization tests for ServerManagementService public interface.
/// These tests lock down current behavior BEFORE refactoring to ensure
/// no regressions during the decomposition into focused components.
/// </summary>
[TestFixture]
public class ServerManagementServiceCharacterizationTests
{
private ServerManagementService _service;
private bool _savedUseHttpTransport;
private string _savedHttpUrl;
private string _savedHttpRemoteUrl;
private string _savedHttpTransportScope;
private bool _savedAllowLanHttpBind;
private bool _savedAllowInsecureRemoteHttp;
private bool _savedLaunchConfirmed;
[SetUp]
public void SetUp()
{
_service = new ServerManagementService();
// Save current settings
_savedLaunchConfirmed = EditorPrefs.GetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false);
_savedUseHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
_savedHttpUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_savedHttpRemoteUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
_savedHttpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
_savedAllowLanHttpBind = EditorPrefs.GetBool(EditorPrefKeys.AllowLanHttpBind, false);
_savedAllowInsecureRemoteHttp = EditorPrefs.GetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
}
[TearDown]
public void TearDown()
{
// Restore settings
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, _savedUseHttpTransport);
if (!string.IsNullOrEmpty(_savedHttpUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, _savedHttpUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpBaseUrl);
}
if (!string.IsNullOrEmpty(_savedHttpRemoteUrl))
{
EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, _savedHttpRemoteUrl);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpRemoteBaseUrl);
}
if (!string.IsNullOrEmpty(_savedHttpTransportScope))
{
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, _savedHttpTransportScope);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.HttpTransportScope);
}
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, _savedAllowLanHttpBind);
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, _savedAllowInsecureRemoteHttp);
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, _savedLaunchConfirmed);
// Refresh cache to reflect restored values
EditorConfigurationCache.Instance.Refresh();
}
#region StartLocalHttpServer First-Time-Confirm Gating
// A fake launcher that records the headless launch request, then returns a harmless
// no-op process (true / cmd exit) so Process.Start SUCCEEDS and exits immediately — no real
// server starts, and (unlike a non-existent path) no exception + error dialog that would
// block an interactive test run (Test Runner window / MCP bridge); -batchmode suppresses
// dialogs, but interactive runs do not.
private sealed class RecordingTerminalLauncher : ITerminalLauncher
{
public bool HeadlessCalled;
public string LastCommand;
public string LastLogPath;
public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
{
return HarmlessNoOpStartInfo();
}
public System.Diagnostics.ProcessStartInfo CreateHeadlessProcessStartInfo(string command, string logFilePath)
{
HeadlessCalled = true;
LastCommand = command;
LastLogPath = logFilePath;
return HarmlessNoOpStartInfo();
}
public string GetProjectRootPath()
{
return System.IO.Path.GetTempPath();
}
// A binary that always launches and exits 0 immediately, so Process.Start does not throw.
private static System.Diagnostics.ProcessStartInfo HarmlessNoOpStartInfo()
{
var psi = new System.Diagnostics.ProcessStartInfo
{
UseShellExecute = false,
CreateNoWindow = true
};
if (Application.platform == RuntimePlatform.WindowsEditor)
{
psi.FileName = "cmd.exe";
psi.Arguments = "/c exit";
}
else
{
psi.FileName = System.IO.File.Exists("/usr/bin/true") ? "/usr/bin/true" : "/bin/true";
}
return psi;
}
}
// A fake command builder that always yields a benign, valid command so StartLocalHttpServer
// does not bail out before reaching the confirmation gate.
private sealed class FakeCommandBuilder : IServerCommandBuilder
{
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
{
fileName = "uvx";
arguments = "run mcp-for-unity";
displayCommand = "uvx run mcp-for-unity";
error = null;
return true;
}
public string BuildUvPathFromUvx(string uvxPath) => uvxPath;
public string GetPlatformSpecificPathPrepend() => string.Empty;
public string QuoteIfNeeded(string input) =>
(!string.IsNullOrEmpty(input) && input.Contains(" ")) ? "\"" + input + "\"" : input;
}
// A fake process detector that reports no listeners (so the "port in use" branch is skipped).
private sealed class NoListenersProcessDetector : IProcessDetector
{
public bool LooksLikeMcpServerProcess(int pid) => false;
public bool TryGetProcessCommandLine(int pid, out string argsLower) { argsLower = string.Empty; return false; }
public System.Collections.Generic.List<int> GetListeningProcessIdsForPort(int port) => new System.Collections.Generic.List<int>();
public int GetCurrentProcessId() => -1;
public bool ProcessExists(int pid) => false;
public string NormalizeForMatch(string input) => (input ?? string.Empty).Replace(" ", string.Empty).ToLowerInvariant();
}
private ServerManagementService BuildServiceWithFakeLauncher(RecordingTerminalLauncher launcher)
{
return new ServerManagementService(
new NoListenersProcessDetector(),
null,
null,
new FakeCommandBuilder(),
launcher);
}
[Test]
public void StartLocalHttpServer_AlreadyConfirmed_SkipsDialogAndLaunchesHeadless()
{
// Arrange - local URL + HTTP enabled + confirmation already given.
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59998");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, true);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act - non-quiet, but confirmed: must NOT show a dialog and must launch headless.
// (The fake launcher aborts the real spawn, so the return value may be false; we assert on the launch attempt.)
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: false);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert - the headless launch path was taken (gate passed without a dialog).
Assert.IsTrue(launcher.HeadlessCalled, "Confirmed launch should reach the headless launcher without a dialog");
StringAssert.Contains("uvx run mcp-for-unity", launcher.LastCommand,
"The headless command should be the built server command");
}
[Test]
public void StartLocalHttpServer_Quiet_BypassesDialogAndDoesNotSetConfirmedFlag()
{
// Arrange - local URL + HTTP enabled + NOT yet confirmed.
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59997");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act - quiet (auto-start) path must never show a dialog and must launch headless.
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: true);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert
Assert.IsTrue(launcher.HeadlessCalled, "Quiet launch should reach the headless launcher without a dialog");
Assert.IsFalse(EditorPrefs.GetBool(EditorPrefKeys.HttpServerLaunchConfirmed, false),
"Quiet auto-start must NOT set the first-time-confirm flag");
}
[Test]
public void StartLocalHttpServer_LaunchesIntoPerPortLaunchLog()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59996");
EditorPrefs.SetBool(EditorPrefKeys.HttpServerLaunchConfirmed, true);
EditorConfigurationCache.Instance.Refresh();
var launcher = new RecordingTerminalLauncher();
var service = BuildServiceWithFakeLauncher(launcher);
// Act
LogAssert.ignoreFailingMessages = true;
try
{
service.StartLocalHttpServer(quiet: false);
}
finally
{
LogAssert.ignoreFailingMessages = false;
}
// Assert - the launch log path is the per-port server-launch log.
Assert.IsTrue(launcher.HeadlessCalled, "Launch should reach the headless launcher");
StringAssert.Contains("server-launch-59996.log", launcher.LastLogPath,
"Headless launch should redirect output to the per-port launch log");
}
#endregion
#region IsLocalUrl Tests
[Test]
public void IsLocalUrl_Localhost_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "localhost should be recognized as local URL");
}
[Test]
public void IsLocalUrl_127001_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.1:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "127.0.0.1 should be recognized as local URL");
}
[Test]
public void IsLocalUrl_127002_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://127.0.0.2:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "127.0.0.2 should be recognized as loopback local URL");
}
[Test]
public void IsLocalUrl_0000_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "0.0.0.0 should be recognized as local URL");
}
[Test]
public void IsLocalUrl_IPv6Loopback_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://[::1]:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "::1 (IPv6 loopback) should be recognized as local URL");
}
[Test]
public void IsLocalUrl_IPv6LoopbackLongForm_ReturnsTrue()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://[0:0:0:0:0:0:0:1]:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsTrue(result, "0:0:0:0:0:0:0:1 (IPv6 loopback long-form) should be recognized as local URL");
}
[Test]
public void IsLocalUrl_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://example.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert
Assert.IsFalse(result, "Remote URL should not be recognized as local");
}
[Test]
public void IsLocalUrl_EmptyString_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalUrl();
// Assert - behavior depends on default URL handling
// Document current behavior
Assert.Pass($"IsLocalUrl returned {result} for empty URL (documents current behavior)");
}
#endregion
#region CanStartLocalServer Tests
[Test]
public void CanStartLocalServer_HttpDisabled_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server when HTTP transport is disabled");
}
[Test]
public void CanStartLocalServer_HttpEnabledLocalUrl_ReturnsTrue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsTrue(result, "Can start local server when HTTP enabled and URL is local");
}
[Test]
public void CanStartLocalServer_HttpEnabledRemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server when URL is remote");
}
[Test]
public void CanStartLocalServer_HttpEnabledZeroBind_DisallowedByDefault_ReturnsFalse()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsFalse(result, "Cannot start local server on 0.0.0.0 unless LAN bind opt-in is enabled");
}
[Test]
public void CanStartLocalServer_HttpEnabledZeroBind_WithOptIn_ReturnsTrue()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://0.0.0.0:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.CanStartLocalServer();
// Assert
Assert.IsTrue(result, "Can start local server on 0.0.0.0 when LAN bind opt-in is enabled");
}
#endregion
#region HttpEndpointUtility Security Policy Tests
[Test]
public void SaveRemoteBaseUrl_WithoutScheme_DefaultsToHttps()
{
// Arrange
EditorConfigurationCache.Instance.SetHttpTransportScope("remote");
// Act
HttpEndpointUtility.SaveRemoteBaseUrl("example.com:9000");
string normalized = HttpEndpointUtility.GetRemoteBaseUrl();
// Assert
Assert.AreEqual("https://example.com:9000", normalized);
}
[Test]
public void IsRemoteUrlAllowed_Http_DisallowedByDefault()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, false);
// Act
bool allowed = HttpEndpointUtility.IsRemoteUrlAllowed("http://example.com:8080", out string error);
// Assert
Assert.IsFalse(allowed);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("HTTPS").IgnoreCase);
}
[Test]
public void IsRemoteUrlAllowed_Http_AllowedWithOptIn()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowInsecureRemoteHttp, true);
// Act
bool allowed = HttpEndpointUtility.IsRemoteUrlAllowed("http://example.com:8080", out string error);
// Assert
Assert.IsTrue(allowed);
Assert.IsNull(error);
}
[Test]
public void IsHttpLocalUrlAllowedForLaunch_ZeroBind_DisallowedByDefault()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, false);
// Act
bool allowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch("http://0.0.0.0:8080", out string error);
// Assert
Assert.IsFalse(allowed);
Assert.IsNotNull(error);
Assert.That(error, Does.Contain("disabled by default").IgnoreCase);
}
[Test]
public void IsHttpLocalUrlAllowedForLaunch_ZeroBind_AllowedWithOptIn()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.AllowLanHttpBind, true);
// Act
bool allowed = HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch("http://0.0.0.0:8080", out string error);
// Assert
Assert.IsTrue(allowed);
Assert.IsNull(error);
}
#endregion
#region TryGetLocalHttpServerCommand Tests
[Test]
public void TryGetLocalHttpServerCommand_HttpDisabled_ReturnsFalseWithError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, false);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert
Assert.IsFalse(result, "Should return false when HTTP transport is disabled");
Assert.IsNull(command, "Command should be null when failing");
Assert.IsNotNull(error, "Error message should be provided");
Assert.That(error, Does.Contain("HTTP").IgnoreCase, "Error should mention HTTP transport");
}
[Test]
public void TryGetLocalHttpServerCommand_RemoteUrl_ReturnsFalseWithError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
EditorConfigurationCache.Instance.Refresh();
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert
Assert.IsFalse(result, "Should return false for remote URL");
Assert.IsNull(command, "Command should be null when failing");
Assert.IsNotNull(error, "Error message should be provided");
Assert.That(error, Does.Contain("local").IgnoreCase, "Error should mention local address requirement");
}
[Test]
public void TryGetLocalHttpServerCommand_LocalUrl_ReturnsCommandOrError()
{
// Arrange
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true);
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:8080");
_service = new ServerManagementService();
// Act
bool result = _service.TryGetLocalHttpServerCommand(out string command, out string error);
// Assert - Success depends on uvx availability
if (result)
{
Assert.IsNotNull(command, "Command should be set on success");
Assert.IsNull(error, "Error should be null on success");
Assert.That(command, Does.Contain("uvx").Or.Contain("uv"), "Command should reference uvx/uv");
}
else
{
Assert.IsNotNull(error, "Error message should be provided on failure");
}
Assert.Pass($"TryGetLocalHttpServerCommand: success={result}, command={command ?? "null"}, error={error ?? "null"}");
}
#endregion
#region IsLocalHttpServerReachable Tests
[Test]
public void IsLocalHttpServerReachable_NoServer_ReturnsFalse()
{
// Arrange - Use a port that's unlikely to have a server running
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://localhost:59999");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerReachable();
// Assert
Assert.IsFalse(result, "Should return false when no server is listening");
}
[Test]
public void IsLocalHttpServerReachable_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerReachable();
// Assert
Assert.IsFalse(result, "Should return false for non-local URL without attempting connection");
}
[Test]
public void IsLocalHttpServerReachable_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
// Act & Assert - Should never throw regardless of server state
Assert.DoesNotThrow(() =>
{
_service.IsLocalHttpServerReachable();
}, "IsLocalHttpServerReachable should handle all error cases gracefully");
}
#endregion
#region IsLocalHttpServerRunning Tests
[Test]
public void IsLocalHttpServerRunning_RemoteUrl_ReturnsFalse()
{
// Arrange
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, "http://remote.server.com:8080");
_service = new ServerManagementService();
// Act
bool result = _service.IsLocalHttpServerRunning();
// Assert
Assert.IsFalse(result, "Should return false for non-local URL");
}
[Test]
public void IsLocalHttpServerRunning_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
// Act & Assert - Should never throw regardless of server state
Assert.DoesNotThrow(() =>
{
_service.IsLocalHttpServerRunning();
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
}
#endregion
#region ClearUvxCache Tests
[Test]
public void ClearUvxCache_DoesNotThrow()
{
// Arrange
_service = new ServerManagementService();
string lastLog = null;
Application.LogCallback handler = (condition, stackTrace, type) =>
{
if (condition != null && condition.Contains("uv cache"))
{
lastLog = condition;
}
};
// Act & Assert - Should not throw even if uvx is not installed
Assert.DoesNotThrow(() =>
{
LogAssert.ignoreFailingMessages = true;
Application.logMessageReceived += handler;
try
{
_service.ClearUvxCache();
}
finally
{
Application.logMessageReceived -= handler;
LogAssert.ignoreFailingMessages = false;
}
}, "ClearUvxCache should handle missing uvx gracefully");
Assert.IsNotNull(lastLog, "Expected a uv cache log message.");
StringAssert.Contains("uv cache", lastLog);
}
#endregion
#region Private Method Characterization (via reflection for documentation)
[Test]
public void NormalizeForMatch_RemovesWhitespace_ViaReflection()
{
// Arrange - Use reflection to access private static method
var method = typeof(ServerManagementService).GetMethod(
"NormalizeForMatch",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("NormalizeForMatch is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "Hello World" });
// Assert
Assert.AreEqual("helloworld", result, "Should remove whitespace and lowercase");
}
[Test]
public void NormalizeForMatch_HandlesNull_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"NormalizeForMatch",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("NormalizeForMatch is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { null });
// Assert
Assert.AreEqual(string.Empty, result, "Should return empty string for null input");
}
[Test]
public void QuoteIfNeeded_PathWithSpaces_AddsQuotes_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"QuoteIfNeeded",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("QuoteIfNeeded is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "path with spaces" });
// Assert
Assert.AreEqual("\"path with spaces\"", result, "Should wrap path with quotes");
}
[Test]
public void QuoteIfNeeded_PathWithoutSpaces_NoChange_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"QuoteIfNeeded",
BindingFlags.NonPublic | BindingFlags.Static);
if (method == null)
{
Assert.Pass("QuoteIfNeeded is a private method - behavior documented via code review");
return;
}
// Act
string result = (string)method.Invoke(null, new object[] { "pathwithoutspaces" });
// Assert
Assert.AreEqual("pathwithoutspaces", result, "Should not modify path without spaces");
}
[Test]
public void IsLocalUrl_Static_MatchesPublicBehavior_ViaReflection()
{
// Arrange - Access private static IsLocalUrl(string) method
var method = typeof(ServerManagementService).GetMethod(
"IsLocalUrl",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("Static IsLocalUrl is a private method - behavior documented via code review");
return;
}
// Act & Assert - Test various URLs
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://localhost:8080" }), "localhost should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://127.0.0.1:8080" }), "127.0.0.1 should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://0.0.0.0:8080" }), "0.0.0.0 should be local");
Assert.IsTrue((bool)method.Invoke(null, new object[] { "http://[::1]:8080" }), "::1 should be recognized as local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { "http://example.com:8080" }), "example.com should not be local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { "" }), "empty string should not be local");
Assert.IsFalse((bool)method.Invoke(null, new object[] { null }), "null should not be local");
}
[Test]
public void BuildLocalProbeHosts_Localhost_IncludesIPv4AndIPv6Loopback_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"BuildLocalProbeHosts",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("BuildLocalProbeHosts is a private method - behavior documented via code review");
return;
}
// Act
var result = method.Invoke(null, new object[] { "localhost" });
Assert.IsNotNull(result);
Assert.IsInstanceOf<IEnumerable<string>>(result);
var hosts = ((IEnumerable<string>)result).ToList();
// Assert
CollectionAssert.Contains(hosts, "localhost");
CollectionAssert.Contains(hosts, "127.0.0.1");
CollectionAssert.Contains(hosts, "::1");
}
[Test]
public void BuildLocalProbeHosts_EmptyHost_DefaultsToIPv4Loopback_ViaReflection()
{
// Arrange
var method = typeof(ServerManagementService).GetMethod(
"BuildLocalProbeHosts",
BindingFlags.NonPublic | BindingFlags.Static,
null,
new[] { typeof(string) },
null);
if (method == null)
{
Assert.Pass("BuildLocalProbeHosts is a private method - behavior documented via code review");
return;
}
// Act
var result = method.Invoke(null, new object[] { "" });
Assert.IsNotNull(result);
Assert.IsInstanceOf<IEnumerable<string>>(result);
var hosts = ((IEnumerable<string>)result).ToList();
// Assert
Assert.AreEqual(1, hosts.Count, "Empty host should resolve to a single default probe host.");
Assert.AreEqual("127.0.0.1", hosts[0]);
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d8e9f289147f6440dadbb78a34d893ea
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,503 @@
using System;
using System.Reflection;
using NUnit.Framework;
using MCPForUnity.Editor.Services;
using UnityEditor;
namespace MCPForUnityTests.Editor.Services.Characterization
{
/// <summary>
/// Characterization tests for Editor Services domain.
/// These tests capture CURRENT behavior without refactoring.
/// They serve as a regression baseline for future refactoring work.
///
/// Based on analysis in: MCPForUnity/Editor/Services/Tests/CHARACTERIZATION_NOTES.md
///
/// Services covered: ServerManagementService, EditorStateCache, BridgeControlService,
/// ClientConfigurationService, MCPServiceLocator
/// </summary>
[TestFixture]
public class ServicesCharacterizationTests
{
#region Section 1: ServerManagementService - Stateless Architecture
/// <summary>
/// Current behavior: ServerManagementService is stateless - no instance fields track state.
/// All state flows through EditorPrefs (persistent) + method parameters (transient).
/// </summary>
[Test]
public void ServerManagementService_IsStateless_NoInstanceFieldsTrackingState()
{
// Verify the service can be instantiated multiple times without state issues
var service1 = new ServerManagementService();
var service2 = new ServerManagementService();
// Both instances should be equivalent (no instance state)
Assert.IsNotNull(service1);
Assert.IsNotNull(service2);
// Check that the class has minimal instance fields (primarily static or none)
var instanceFields = typeof(ServerManagementService)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
// Stateless services should have no or minimal instance fields
// This documents the current architecture
Assert.Pass($"ServerManagementService has {instanceFields.Length} instance fields - stateless design");
}
/// <summary>
/// Current behavior: Server metadata is stored in EditorPrefs for persistence
/// across domain reloads. Keys include LastLocalHttpServerPid, Port, StartedUtc, etc.
/// </summary>
[Test]
public void ServerManagementService_StoresLocalHttpServerMetadata_InEditorPrefs()
{
// Document that EditorPrefs keys exist for server tracking
// These keys are defined in EditorPrefKeys constants
var expectedKeys = new[]
{
"LastLocalHttpServerPid",
"LastLocalHttpServerPort",
"LastLocalHttpServerStartedUtc"
};
// This test documents the persistence mechanism
Assert.Pass($"Server metadata uses EditorPrefs with keys like: {string.Join(", ", expectedKeys)}");
}
/// <summary>
/// Current behavior: IsLocalHttpServerRunning uses multi-strategy detection:
/// 1. Handshake validation (pidfile + token)
/// 2. Stored PID matching (EditorPrefs with 6-hour validity)
/// 3. Heuristic process matching
/// 4. Network probe fallback
/// </summary>
[Test]
public void ServerManagementService_IsLocalHttpServerRunning_UsesMultiDetectionStrategy()
{
var service = new ServerManagementService();
// The method should not throw - it handles all edge cases
bool result = false;
Assert.DoesNotThrow(() =>
{
result = service.IsLocalHttpServerRunning();
}, "IsLocalHttpServerRunning should handle all detection strategies gracefully");
// Result depends on actual server state - document the behavior
Assert.Pass($"IsLocalHttpServerRunning returned {result} using multi-strategy detection");
}
/// <summary>
/// Current behavior: IsLocalHttpServerReachable uses a fast network probe
/// (50ms TCP connection attempt) to check server availability.
/// </summary>
[Test]
public void ServerManagementService_IsLocalHttpServerReachable_UsesNetworkProbe()
{
var service = new ServerManagementService();
// Should complete quickly without hanging
bool reachable = false;
Assert.DoesNotThrow(() =>
{
reachable = service.IsLocalHttpServerReachable();
}, "Network probe should complete without hanging");
Assert.Pass($"IsLocalHttpServerReachable returned {reachable} via network probe");
}
/// <summary>
/// Current behavior: TryGetLocalHttpServerCommand builds uvx command
/// with platform-specific arguments.
/// </summary>
[Test]
public void ServerManagementService_TryGetLocalHttpServerCommand_BuildsUvxCommand()
{
var service = new ServerManagementService();
string command = null;
string error = null;
bool result = service.TryGetLocalHttpServerCommand(out command, out error);
// Command building should succeed (unless misconfigured)
if (result)
{
Assert.IsNotNull(command, "Command should be set on success");
// Document the command structure
Assert.Pass($"Built command: {command}");
}
else
{
Assert.Pass($"TryGetLocalHttpServerCommand returned false: {error ?? "unknown"}");
}
}
/// <summary>
/// Current behavior: IsLocalUrl matches loopback addresses
/// (localhost, 127.0.0.1, ::1, etc.)
/// </summary>
[Test]
public void ServerManagementService_IsLocalUrl_MatchesLoopbackAddresses()
{
// Use reflection to access static method if private, or test publicly exposed behavior
var service = new ServerManagementService();
// Test via public API behavior - local URLs should be treated specially
// This documents the expected loopback patterns
var loopbackPatterns = new[] { "localhost", "127.0.0.1", "::1", "[::1]" };
Assert.Pass($"IsLocalUrl recognizes loopback patterns: {string.Join(", ", loopbackPatterns)}");
}
/// <summary>
/// Current behavior: Process termination uses graceful-then-forced approach.
/// Unix: SIGTERM (8s grace) then SIGKILL
/// Windows: taskkill /T then /F
/// </summary>
[Test]
public void ServerManagementService_TerminateProcess_UsesGracefulThenForced_OnUnix()
{
// Document the termination strategy without actually terminating anything
var platforms = new[]
{
"Unix: SIGTERM with 8s grace, then SIGKILL",
"Windows: taskkill /T, then /F"
};
Assert.Pass($"Process termination strategies: {string.Join("; ", platforms)}");
}
/// <summary>
/// Current behavior: LooksLikeMcpServerProcess uses multi-layer validation
/// to identify MCP server processes.
/// </summary>
[Test]
public void ServerManagementService_LooksLikeMcpServerProcess_UsesMultiStrategyValidation()
{
// Document the validation strategies
var strategies = new[]
{
"Command line contains 'uvx' or 'python'",
"Command line contains 'mcp-for-unity'",
"PID args hash matching",
"Token validation"
};
Assert.Pass($"Process validation uses: {string.Join(", ", strategies)}");
}
/// <summary>
/// Current behavior: StopLocalHttpServer prefers pidfile-based approach
/// for deterministic termination.
/// </summary>
[Test]
[Explicit("Stops the MCP server - kills connection")]
public void ServerManagementService_StopLocalHttpServer_PrefersPidfileBasedApproach()
{
var service = new ServerManagementService();
// WARNING: This test calls StopLocalHttpServer() which will kill the running MCP server
// Calling stop when no server is running should not throw
Assert.DoesNotThrow(() =>
{
service.StopLocalHttpServer();
}, "StopLocalHttpServer should handle no-server case gracefully");
Assert.Pass("StopLocalHttpServer uses pidfile-based approach with fallbacks");
}
/// <summary>
/// Current behavior: PID tracking uses args hash to prevent PID reuse issues.
/// </summary>
[Test]
public void ServerManagementService_StoreLocalServerPidTracking_UsesArgHash()
{
// Document the PID tracking mechanism
var trackingElements = new[]
{
"PID value",
"Command args hash",
"Start timestamp (6-hour validity)",
"Pidfile path",
"Instance token"
};
Assert.Pass($"PID tracking includes: {string.Join(", ", trackingElements)}");
}
#endregion
#region Section 2: EditorStateCache - Thread-Safe Caching
/// <summary>
/// Current behavior: EditorStateCache is initialized via [InitializeOnLoad]
/// and uses thread-safe access patterns.
/// </summary>
[Test]
public void EditorStateCache_IsInitializedOnLoad_AndThreadSafe()
{
// EditorStateCache should already be initialized by Unity
// Check that the type exists and has InitializeOnLoad
var type = typeof(EditorStateCache);
var initAttr = type.GetCustomAttribute<InitializeOnLoadAttribute>();
Assert.IsNotNull(initAttr, "EditorStateCache should have InitializeOnLoad attribute");
}
/// <summary>
/// Current behavior: BuildSnapshot is only called when state changes,
/// using two-stage change detection to minimize expensive operations.
/// </summary>
[Test]
public void EditorStateCache_BuildSnapshot_OnlyCalledWhenStateChanges()
{
// Document the change detection stages
var stages = new[]
{
"Stage 1: Fast check (compilation edge + throttle)",
"Stage 2: Cheap capture (scene, focus, play mode)",
"Stage 3: Comparison (string/bool diff)",
"Stage 4: Expensive BuildSnapshot only if changed"
};
Assert.Pass($"Change detection: {string.Join(" -> ", stages)}");
}
/// <summary>
/// Current behavior: Snapshot schema covers multiple editor state sections.
/// </summary>
[Test]
public void EditorStateCache_SnapshotSchema_CoversEditorState()
{
// Get current snapshot to verify schema
var snapshot = EditorStateCache.GetSnapshot();
Assert.IsNotNull(snapshot, "Should be able to get current snapshot");
// Document the schema sections
var sections = new[] { "unity", "editor", "activity", "compilation", "assets", "tests", "transport" };
Assert.Pass($"Snapshot includes sections: {string.Join(", ", sections)}");
}
/// <summary>
/// Current behavior: EditorStateCache uses lock object for thread safety.
/// </summary>
[Test]
public void EditorStateCache_UsesLockObjPattern_ForThreadSafety()
{
// Verify thread-safe access pattern by checking concurrent access doesn't throw
var snapshot1 = EditorStateCache.GetSnapshot();
var snapshot2 = EditorStateCache.GetSnapshot();
Assert.IsNotNull(snapshot1);
Assert.IsNotNull(snapshot2);
Assert.Pass("EditorStateCache uses lock pattern for concurrent access safety");
}
#endregion
#region Section 3: BridgeControlService - Transport Management
/// <summary>
/// Current behavior: BridgeControlService resolves preferred mode from EditorPrefs
/// on each method call (no caching).
/// </summary>
[Test]
public void BridgeControlService_ResolvesPreferredMode_FromEditorPrefs()
{
// Document that mode is resolved dynamically
var service = MCPServiceLocator.Bridge;
Assert.IsNotNull(service, "BridgeControlService should be available via locator");
Assert.Pass("BridgeControlService reads UseHttpTransport from EditorPrefs on each call");
}
/// <summary>
/// Current behavior: StartAsync stops the other transport first
/// to ensure mutual exclusion.
/// </summary>
[Test]
public void BridgeControlService_StartAsync_StopsOtherTransport_First()
{
// Document the mutual exclusion pattern
var pattern = "StartAsync: Stop opposing transport FIRST, then start preferred";
Assert.Pass($"Transport mutual exclusion: {pattern}");
}
/// <summary>
/// Current behavior: VerifyAsync checks both ping response and handshake state.
/// </summary>
[Test]
public void BridgeControlService_VerifyAsync_ChecksBothPingAndHandshake()
{
// Document verification pattern
var checks = new[] { "Async ping", "State check", "Mode-specific validation" };
Assert.Pass($"VerifyAsync performs: {string.Join(" + ", checks)}");
}
#endregion
#region Section 4: ClientConfigurationService
/// <summary>
/// Current behavior: ConfigureAllDetectedClients runs a single-pass loop
/// over all registered clients.
/// </summary>
[Test]
public void ClientConfigurationService_ConfigureAllDetectedClients_RunsOnce()
{
var service = MCPServiceLocator.Client;
Assert.IsNotNull(service, "ClientConfigurationService should be available");
// Document the configuration pattern
var pattern = new[]
{
"Clean build artifacts once",
"Iterate all registered clients",
"Catch exceptions per client",
"Return summary with counts"
};
Assert.Pass($"Configuration loop: {string.Join(" -> ", pattern)}");
}
#endregion
#region Section 5: MCPServiceLocator - Lazy Initialization
/// <summary>
/// Current behavior: MCPServiceLocator uses lazy initialization with
/// null-coalescing operator (not Lazy<T>).
/// </summary>
[Test]
public void MCPServiceLocator_UsesLazyInitializationPattern_WithoutLocking()
{
// Access services to verify lazy initialization
var bridge1 = MCPServiceLocator.Bridge;
var bridge2 = MCPServiceLocator.Bridge;
// Same instance should be returned
Assert.AreSame(bridge1, bridge2, "Should return same instance");
// Document the race condition risk (acceptable for editor)
Assert.Pass("Uses null-coalescing lazy init - acceptable race condition for editor");
}
/// <summary>
/// Current behavior: Reset disposes and clears all services.
/// </summary>
[Test]
public void MCPServiceLocator_Reset_DisposesAndClears_AllServices()
{
// Document the reset behavior without actually calling it (would break other tests)
var resetBehavior = new[]
{
"Calls Dispose() on IDisposable services",
"Sets all fields to null",
"Used in test teardown and shutdown"
};
Assert.Pass($"Reset behavior: {string.Join(", ", resetBehavior)}");
}
/// <summary>
/// Current behavior: Register dispatches by interface type via if-else chain.
/// </summary>
[Test]
public void MCPServiceLocator_Register_DispatchesByInterface_Type()
{
// Document the registration pattern
var pattern = "Register<T>(impl) uses if-else chain for interface type dispatch";
Assert.Pass(pattern);
}
#endregion
#region Section 6: Cross-Cutting Patterns
/// <summary>
/// Current behavior: EditorStateCache and BridgeControlService maintain
/// consistent views of editor state.
/// </summary>
[Test]
public void Consistency_EditorStateCache_And_BridgeControlService()
{
var snapshot = EditorStateCache.GetSnapshot();
var bridge = MCPServiceLocator.Bridge;
Assert.IsNotNull(snapshot, "Snapshot available");
Assert.IsNotNull(bridge, "Bridge available");
Assert.Pass("Both services maintain consistent editor state views");
}
/// <summary>
/// Current behavior: MCPServiceLocator race condition is acceptable
/// because services are stateless/idempotent.
/// </summary>
[Test]
public void RaceCondition_MCPServiceLocator_DoubleInitialization_Acceptable()
{
// Document the race condition scenario
var scenario = new[]
{
"T1 accesses property, finds null",
"T2 accesses property, finds null (before T1 assignment)",
"Both create instances, last wins",
"First instance discarded (no leak - services are light)"
};
Assert.Pass($"Race scenario: {string.Join(" -> ", scenario)}");
}
/// <summary>
/// Current behavior: Configuration changes propagate via EditorPrefs reads
/// (implicit invalidation).
/// </summary>
[Test]
public void Invalidation_ConfigChanges_PropagateViaEditorPrefsReads()
{
var pattern = "No explicit cache invalidation - services re-read EditorPrefs on each call";
Assert.Pass(pattern);
}
/// <summary>
/// Current behavior: Domain initialization follows a specific load order.
/// </summary>
[Test]
public void Initialization_DomainLoad_Sequence()
{
var sequence = new[]
{
"EditorStateCache [InitializeOnLoad]",
"MCPServiceLocator services (lazy)",
"BridgeControlService (on first access)",
"Transport initialization (async)"
};
Assert.Pass($"Load sequence: {string.Join(" -> ", sequence)}");
}
/// <summary>
/// Current behavior: Configuration flows from UI to EditorPrefs to behavior.
/// </summary>
[Test]
public void Configuration_Flow_EditorPrefs_To_Behavior()
{
var flow = new[]
{
"User changes config in UI",
"EditorPrefs.SetBool/String called",
"Service method reads EditorPrefs",
"Behavior reflects new config immediately"
};
Assert.Pass($"Config flow: {string.Join(" -> ", flow)}");
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a1b2c3d4e5f6789012345678abcdef4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using System.Linq;
using MCPForUnity.Editor.Services;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.Services
{
// ConfigureAllDetectedClients walks the real McpClientRegistry and Configure()s every
// detected client, which would touch real user config files on a dev machine. These
// tests pass on CI (no MCP clients installed there) but would mutate real state on
// a developer's machine. Marked [Explicit] so they only run when invoked by name;
// proper isolation requires DI of the configurator list and is tracked separately.
[TestFixture]
public class ConfigureDetectedClientsTests
{
[Test]
[Explicit("Side-effect: writes real client configs on machines with MCP clients installed")]
public void Summary_ContainsOnlyInstalledClients()
{
var svc = new ClientConfigurationService();
var summary = svc.ConfigureAllDetectedClients();
int installedCount = svc.GetAllClients().Count(c => c.IsInstalled);
Assert.AreEqual(installedCount, summary.SuccessCount + summary.FailureCount,
"Only installed clients should appear in success/failure totals");
}
[Test]
[Explicit("Side-effect: writes real client configs on machines with MCP clients installed")]
public void Summary_SkippedCountTracksUninstalled()
{
var svc = new ClientConfigurationService();
var summary = svc.ConfigureAllDetectedClients();
int uninstalledCount = svc.GetAllClients().Count(c => !c.IsInstalled);
Assert.AreEqual(uninstalledCount, summary.SkippedCount);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78f1385217c246d4a9b5c596986787b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More