chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+165
@@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7db2353f825a4ab7af5df4b6393309df
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+110
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+76
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+38
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+899
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8e9f289147f6440dadbb78a34d893ea
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+503
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a1b2c3d4e5f6789012345678abcdef4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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
Reference in New Issue
Block a user