@mflrevan/ucp 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bridge/com.ucp.bridge/CHANGELOG.md +32 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/AssetController.cs +50 -19
- package/bridge/com.ucp.bridge/Editor/Controllers/EditorSettingsController.cs +4 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs +3 -6
- package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +93 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PlayModeController.cs +46 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +5 -23
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +42 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +62 -2
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +403 -7
- package/bridge/com.ucp.bridge/package.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mflrevan/ucp
|
|
2
2
|
|
|
3
|
-
Version `0.
|
|
3
|
+
Version `0.3.1` of the Unity Control Protocol CLI.
|
|
4
4
|
|
|
5
5
|
This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
|
|
6
6
|
|
|
@@ -42,12 +42,14 @@ ucp build targets
|
|
|
42
42
|
ucp install
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
Default `ucp install` writes a tracked git dependency to `Packages/manifest.json` pinned to the CLI version. It does not write a local `file:` dependency unless you explicitly choose a local embedded mode.
|
|
46
|
+
|
|
45
47
|
Or add this to `Packages/manifest.json`:
|
|
46
48
|
|
|
47
49
|
```json
|
|
48
50
|
{
|
|
49
51
|
"dependencies": {
|
|
50
|
-
"com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.
|
|
52
|
+
"com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.3.1"
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
```
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.1] - 2026-03-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Added `asset/write-batch` for multi-field serialized asset updates in one bridge call.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Player settings now expose `defaultIsNativeResolution` so installer automation can reconcile live editor state as well as on-disk project settings.
|
|
12
|
+
- Object reference payloads now include asset `path` and `guid` when available.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fixed buffered log searches by applying regex filtering before count truncation.
|
|
17
|
+
- Fixed buffered log list requests being capped to 10 returned entries regardless of requested `count`.
|
|
18
|
+
- Fixed serialized object reference writes silently accepting unresolved references in both object and asset controllers.
|
|
19
|
+
|
|
20
|
+
## [0.3.0] - 2026-03-13
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Dirty-scene automation controls in bridge scene/play handlers so unattended CLI workflows can avoid save-confirmation modal interruptions.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Scene load and play entry now auto-handle dirty scenes by default for non-interactive automation flows.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Fixed edit-mode test execution when called during Play Mode by deferring run start until Play Mode exits.
|
|
33
|
+
- Fixed workflow interruptions caused by Unity save-scene prompts during scene transitions and play mode entry.
|
|
34
|
+
|
|
3
35
|
## [0.2.3] - 2026-03-12
|
|
4
36
|
|
|
5
37
|
### Fixed
|
|
@@ -26,7 +26,7 @@ namespace UCP.Bridge
|
|
|
26
26
|
private const int DefaultPort = 21342;
|
|
27
27
|
private const int MaxPort = 21352;
|
|
28
28
|
private const int MaxConnections = 4;
|
|
29
|
-
private const string ProtocolVersion = "0.
|
|
29
|
+
private const string ProtocolVersion = "0.3.1";
|
|
30
30
|
|
|
31
31
|
private static TcpListener s_listener;
|
|
32
32
|
private static CancellationTokenSource s_cts;
|
|
@@ -14,6 +14,7 @@ namespace UCP.Bridge
|
|
|
14
14
|
router.Register("asset/info", HandleInfo);
|
|
15
15
|
router.Register("asset/read", HandleReadScriptableObject);
|
|
16
16
|
router.Register("asset/write", HandleWriteScriptableObject);
|
|
17
|
+
router.Register("asset/write-batch", HandleWriteScriptableObjectBatch);
|
|
17
18
|
router.Register("asset/create-so", HandleCreateScriptableObject);
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -207,15 +208,44 @@ namespace UCP.Bridge
|
|
|
207
208
|
|
|
208
209
|
string fieldName = fieldObj.ToString();
|
|
209
210
|
var so = new SerializedObject(asset);
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
Undo.RecordObject(asset, $"UCP Write {fieldName}");
|
|
212
|
+
WriteFieldValue(so, asset, fieldName, p["value"]);
|
|
213
|
+
so.ApplyModifiedProperties();
|
|
214
|
+
EditorUtility.SetDirty(asset);
|
|
215
|
+
AssetDatabase.SaveAssetIfDirty(asset);
|
|
216
|
+
so.Dispose();
|
|
217
|
+
|
|
218
|
+
return new Dictionary<string, object>
|
|
212
219
|
{
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
["status"] = "ok",
|
|
221
|
+
["path"] = assetPath,
|
|
222
|
+
["field"] = fieldName
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private static object HandleWriteScriptableObjectBatch(string paramsJson)
|
|
227
|
+
{
|
|
228
|
+
var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
|
|
229
|
+
if (p == null || !p.TryGetValue("path", out var pathObj))
|
|
230
|
+
throw new ArgumentException("Missing 'path' parameter");
|
|
231
|
+
if (!p.TryGetValue("values", out var valuesObj) || !(valuesObj is Dictionary<string, object> values))
|
|
232
|
+
throw new ArgumentException("Missing 'values' parameter");
|
|
233
|
+
|
|
234
|
+
string assetPath = pathObj.ToString();
|
|
235
|
+
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
|
|
236
|
+
if (asset == null)
|
|
237
|
+
throw new ArgumentException($"Asset not found: {assetPath}");
|
|
238
|
+
|
|
239
|
+
var so = new SerializedObject(asset);
|
|
240
|
+
Undo.RecordObject(asset, $"UCP Batch Write {asset.name}");
|
|
241
|
+
|
|
242
|
+
var fields = new List<object>();
|
|
243
|
+
foreach (var entry in values)
|
|
244
|
+
{
|
|
245
|
+
WriteFieldValue(so, asset, entry.Key, entry.Value);
|
|
246
|
+
fields.Add(entry.Key);
|
|
215
247
|
}
|
|
216
248
|
|
|
217
|
-
Undo.RecordObject(asset, $"UCP Write {fieldName}");
|
|
218
|
-
WriteSerializedValue(prop, p["value"]);
|
|
219
249
|
so.ApplyModifiedProperties();
|
|
220
250
|
EditorUtility.SetDirty(asset);
|
|
221
251
|
AssetDatabase.SaveAssetIfDirty(asset);
|
|
@@ -225,7 +255,7 @@ namespace UCP.Bridge
|
|
|
225
255
|
{
|
|
226
256
|
["status"] = "ok",
|
|
227
257
|
["path"] = assetPath,
|
|
228
|
-
["
|
|
258
|
+
["fields"] = fields
|
|
229
259
|
};
|
|
230
260
|
}
|
|
231
261
|
|
|
@@ -381,14 +411,7 @@ namespace UCP.Bridge
|
|
|
381
411
|
var c = prop.colorValue;
|
|
382
412
|
return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
|
|
383
413
|
case SerializedPropertyType.ObjectReference:
|
|
384
|
-
|
|
385
|
-
return new Dictionary<string, object>
|
|
386
|
-
{
|
|
387
|
-
["instanceId"] = prop.objectReferenceValue.GetInstanceID(),
|
|
388
|
-
["name"] = prop.objectReferenceValue.name,
|
|
389
|
-
["type"] = prop.objectReferenceValue.GetType().Name
|
|
390
|
-
};
|
|
391
|
-
return null;
|
|
414
|
+
return ObjectReferenceResolver.Serialize(prop.objectReferenceValue);
|
|
392
415
|
case SerializedPropertyType.Enum:
|
|
393
416
|
return prop.enumValueIndex < prop.enumDisplayNames.Length
|
|
394
417
|
? prop.enumDisplayNames[prop.enumValueIndex]
|
|
@@ -483,10 +506,9 @@ namespace UCP.Bridge
|
|
|
483
506
|
prop.quaternionValue = new Quaternion(Convert.ToSingle(qArr[0]), Convert.ToSingle(qArr[1]), Convert.ToSingle(qArr[2]), Convert.ToSingle(qArr[3]));
|
|
484
507
|
break;
|
|
485
508
|
case SerializedPropertyType.ObjectReference:
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
prop.objectReferenceValue = EditorUtility.InstanceIDToObject(Convert.ToInt32(refId));
|
|
509
|
+
prop.objectReferenceValue = ObjectReferenceResolver.Resolve(value, prop.displayName);
|
|
510
|
+
if (value != null && prop.objectReferenceValue == null)
|
|
511
|
+
throw new ArgumentException($"Unable to assign object reference to '{prop.displayName}'");
|
|
490
512
|
break;
|
|
491
513
|
case SerializedPropertyType.LayerMask:
|
|
492
514
|
prop.intValue = Convert.ToInt32(value);
|
|
@@ -495,5 +517,14 @@ namespace UCP.Bridge
|
|
|
495
517
|
throw new ArgumentException($"Cannot write property of type {prop.propertyType}");
|
|
496
518
|
}
|
|
497
519
|
}
|
|
520
|
+
|
|
521
|
+
private static void WriteFieldValue(SerializedObject serializedObject, UnityEngine.Object asset, string fieldName, object value)
|
|
522
|
+
{
|
|
523
|
+
var prop = serializedObject.FindProperty(fieldName);
|
|
524
|
+
if (prop == null)
|
|
525
|
+
throw new ArgumentException($"Field '{fieldName}' not found on {asset.GetType().Name}");
|
|
526
|
+
|
|
527
|
+
WriteSerializedValue(prop, value);
|
|
528
|
+
}
|
|
498
529
|
}
|
|
499
530
|
}
|
|
@@ -30,7 +30,7 @@ namespace UCP.Bridge
|
|
|
30
30
|
["companyName"] = PlayerSettings.companyName,
|
|
31
31
|
["productName"] = PlayerSettings.productName,
|
|
32
32
|
["bundleVersion"] = PlayerSettings.bundleVersion,
|
|
33
|
-
["
|
|
33
|
+
["defaultIsNativeResolution"] = PlayerSettings.defaultIsNativeResolution,
|
|
34
34
|
["runInBackground"] = PlayerSettings.runInBackground,
|
|
35
35
|
["colorSpace"] = PlayerSettings.colorSpace.ToString(),
|
|
36
36
|
["graphicsApi"] = PlayerSettings.GetGraphicsAPIs(EditorUserBuildSettings.activeBuildTarget)?[0].ToString() ?? "Unknown",
|
|
@@ -67,6 +67,9 @@ namespace UCP.Bridge
|
|
|
67
67
|
case "runInBackground":
|
|
68
68
|
PlayerSettings.runInBackground = Convert.ToBoolean(value);
|
|
69
69
|
break;
|
|
70
|
+
case "defaultIsNativeResolution":
|
|
71
|
+
PlayerSettings.defaultIsNativeResolution = Convert.ToBoolean(value);
|
|
72
|
+
break;
|
|
70
73
|
case "defaultScreenWidth":
|
|
71
74
|
PlayerSettings.defaultScreenWidth = Convert.ToInt32(value);
|
|
72
75
|
break;
|
|
@@ -9,7 +9,6 @@ namespace UCP.Bridge
|
|
|
9
9
|
public static class LogsController
|
|
10
10
|
{
|
|
11
11
|
private const int MaxHistoryEntries = 2000;
|
|
12
|
-
private const int MaxBulkResults = 10;
|
|
13
12
|
private const int DefaultSearchWindow = 200;
|
|
14
13
|
private const int MaxPreviewLength = 200;
|
|
15
14
|
|
|
@@ -145,8 +144,6 @@ namespace UCP.Bridge
|
|
|
145
144
|
if (!string.IsNullOrEmpty(query.Level))
|
|
146
145
|
candidates = candidates.Where(entry => PassesLevel(entry.Level, query.Level));
|
|
147
146
|
|
|
148
|
-
candidates = candidates.OrderByDescending(entry => entry.Id).Take(query.Count);
|
|
149
|
-
|
|
150
147
|
if (query.Regex != null)
|
|
151
148
|
{
|
|
152
149
|
candidates = candidates.Where(entry =>
|
|
@@ -155,14 +152,14 @@ namespace UCP.Bridge
|
|
|
155
152
|
);
|
|
156
153
|
}
|
|
157
154
|
|
|
158
|
-
var allMatches = candidates.ToList();
|
|
159
|
-
var returned = allMatches.Take(
|
|
155
|
+
var allMatches = candidates.OrderByDescending(entry => entry.Id).ToList();
|
|
156
|
+
var returned = allMatches.Take(query.Count).Select(SerializeSummary).ToList();
|
|
160
157
|
|
|
161
158
|
return new LogQueryResult
|
|
162
159
|
{
|
|
163
160
|
Total = allMatches.Count,
|
|
164
161
|
Returned = returned,
|
|
165
|
-
Truncated = allMatches.Count >
|
|
162
|
+
Truncated = allMatches.Count > returned.Count
|
|
166
163
|
};
|
|
167
164
|
}
|
|
168
165
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using UnityEditor;
|
|
4
|
+
using UnityEngine;
|
|
5
|
+
|
|
6
|
+
namespace UCP.Bridge
|
|
7
|
+
{
|
|
8
|
+
internal static class ObjectReferenceResolver
|
|
9
|
+
{
|
|
10
|
+
public static Dictionary<string, object> Serialize(UnityEngine.Object obj)
|
|
11
|
+
{
|
|
12
|
+
if (obj == null)
|
|
13
|
+
return null;
|
|
14
|
+
|
|
15
|
+
var result = new Dictionary<string, object>
|
|
16
|
+
{
|
|
17
|
+
["instanceId"] = obj.GetInstanceID(),
|
|
18
|
+
["name"] = obj.name,
|
|
19
|
+
["type"] = obj.GetType().Name
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
var assetPath = AssetDatabase.GetAssetPath(obj);
|
|
23
|
+
if (!string.IsNullOrEmpty(assetPath))
|
|
24
|
+
{
|
|
25
|
+
result["path"] = assetPath;
|
|
26
|
+
var guid = AssetDatabase.AssetPathToGUID(assetPath);
|
|
27
|
+
if (!string.IsNullOrEmpty(guid))
|
|
28
|
+
result["guid"] = guid;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public static UnityEngine.Object Resolve(object value, string propertyName)
|
|
35
|
+
{
|
|
36
|
+
if (value == null)
|
|
37
|
+
return null;
|
|
38
|
+
|
|
39
|
+
if (value is Dictionary<string, object> reference)
|
|
40
|
+
{
|
|
41
|
+
if (reference.TryGetValue("instanceId", out var instanceId) && instanceId != null)
|
|
42
|
+
return ResolveByInstanceId(Convert.ToInt32(instanceId), propertyName);
|
|
43
|
+
|
|
44
|
+
if (reference.TryGetValue("path", out var path) && path != null)
|
|
45
|
+
return ResolveByPath(path.ToString(), propertyName);
|
|
46
|
+
|
|
47
|
+
if (reference.TryGetValue("guid", out var guid) && guid != null)
|
|
48
|
+
return ResolveByGuid(guid.ToString(), propertyName);
|
|
49
|
+
|
|
50
|
+
throw new ArgumentException($"Object reference for '{propertyName}' must include instanceId, path, or guid");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (value is string stringValue)
|
|
54
|
+
{
|
|
55
|
+
return stringValue.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)
|
|
56
|
+
? ResolveByPath(stringValue, propertyName)
|
|
57
|
+
: ResolveByGuid(stringValue, propertyName);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (value is sbyte || value is byte || value is short || value is ushort || value is int || value is uint || value is long || value is ulong)
|
|
61
|
+
return ResolveByInstanceId(Convert.ToInt32(value), propertyName);
|
|
62
|
+
|
|
63
|
+
throw new ArgumentException($"Unsupported object reference value for '{propertyName}'");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static UnityEngine.Object ResolveByInstanceId(int instanceId, string propertyName)
|
|
67
|
+
{
|
|
68
|
+
var resolved = EditorUtility.InstanceIDToObject(instanceId);
|
|
69
|
+
if (resolved == null)
|
|
70
|
+
throw new ArgumentException($"Object reference for '{propertyName}' could not resolve instance id {instanceId}");
|
|
71
|
+
|
|
72
|
+
return resolved;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private static UnityEngine.Object ResolveByPath(string assetPath, string propertyName)
|
|
76
|
+
{
|
|
77
|
+
var resolved = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
|
|
78
|
+
if (resolved == null)
|
|
79
|
+
throw new ArgumentException($"Object reference for '{propertyName}' could not load asset at {assetPath}");
|
|
80
|
+
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private static UnityEngine.Object ResolveByGuid(string guid, string propertyName)
|
|
85
|
+
{
|
|
86
|
+
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
|
87
|
+
if (string.IsNullOrEmpty(assetPath))
|
|
88
|
+
throw new ArgumentException($"Object reference for '{propertyName}' could not resolve guid {guid}");
|
|
89
|
+
|
|
90
|
+
return ResolveByPath(assetPath, propertyName);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
using UnityEditor;
|
|
2
|
+
using UnityEditor.SceneManagement;
|
|
3
|
+
using UnityEngine.SceneManagement;
|
|
4
|
+
using System.Collections.Generic;
|
|
2
5
|
|
|
3
6
|
namespace UCP.Bridge
|
|
4
7
|
{
|
|
@@ -16,10 +19,53 @@ namespace UCP.Bridge
|
|
|
16
19
|
if (EditorApplication.isPlaying)
|
|
17
20
|
return new { status = "already_playing" };
|
|
18
21
|
|
|
22
|
+
var saveDirtyScenes = GetBoolParam(paramsJson, "saveDirtyScenes", true);
|
|
23
|
+
var discardUntitled = GetBoolParam(paramsJson, "discardUntitled", true);
|
|
24
|
+
SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
|
|
25
|
+
|
|
19
26
|
EditorApplication.isPlaying = true;
|
|
20
27
|
return new { status = "ok" };
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
private static bool GetBoolParam(string paramsJson, string key, bool defaultValue)
|
|
31
|
+
{
|
|
32
|
+
var parameters = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
|
|
33
|
+
if (parameters != null && parameters.TryGetValue(key, out var valueObj) && valueObj is bool value)
|
|
34
|
+
return value;
|
|
35
|
+
|
|
36
|
+
return defaultValue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private static void SaveDirtyScenesIfRequested(bool saveDirtyScenes, bool discardUntitled)
|
|
40
|
+
{
|
|
41
|
+
if (!saveDirtyScenes)
|
|
42
|
+
return;
|
|
43
|
+
|
|
44
|
+
var requiresUntitledDiscard = false;
|
|
45
|
+
|
|
46
|
+
for (var index = 0; index < SceneManager.sceneCount; index++)
|
|
47
|
+
{
|
|
48
|
+
var scene = SceneManager.GetSceneAt(index);
|
|
49
|
+
if (!scene.isLoaded || !scene.isDirty)
|
|
50
|
+
continue;
|
|
51
|
+
|
|
52
|
+
if (string.IsNullOrEmpty(scene.path))
|
|
53
|
+
{
|
|
54
|
+
if (!discardUntitled)
|
|
55
|
+
throw new System.InvalidOperationException("Dirty untitled scene cannot be auto-saved. Retry with discardUntitled=true.");
|
|
56
|
+
|
|
57
|
+
requiresUntitledDiscard = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!EditorSceneManager.SaveScene(scene))
|
|
62
|
+
throw new System.InvalidOperationException($"Failed to auto-save dirty scene: {scene.path}");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (requiresUntitledDiscard)
|
|
66
|
+
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
|
67
|
+
}
|
|
68
|
+
|
|
23
69
|
private static object HandleStop(string paramsJson)
|
|
24
70
|
{
|
|
25
71
|
if (!EditorApplication.isPlaying)
|
|
@@ -218,14 +218,7 @@ namespace UCP.Bridge
|
|
|
218
218
|
var c = prop.colorValue;
|
|
219
219
|
return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
|
|
220
220
|
case SerializedPropertyType.ObjectReference:
|
|
221
|
-
|
|
222
|
-
return new Dictionary<string, object>
|
|
223
|
-
{
|
|
224
|
-
["instanceId"] = prop.objectReferenceValue.GetInstanceID(),
|
|
225
|
-
["name"] = prop.objectReferenceValue.name,
|
|
226
|
-
["type"] = prop.objectReferenceValue.GetType().Name
|
|
227
|
-
};
|
|
228
|
-
return null;
|
|
221
|
+
return ObjectReferenceResolver.Serialize(prop.objectReferenceValue);
|
|
229
222
|
case SerializedPropertyType.LayerMask:
|
|
230
223
|
return prop.intValue;
|
|
231
224
|
case SerializedPropertyType.Enum:
|
|
@@ -422,15 +415,9 @@ namespace UCP.Bridge
|
|
|
422
415
|
}
|
|
423
416
|
break;
|
|
424
417
|
case SerializedPropertyType.ObjectReference:
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
prop.
|
|
428
|
-
}
|
|
429
|
-
else if (value is Dictionary<string, object> refDict && refDict.TryGetValue("instanceId", out var refId))
|
|
430
|
-
{
|
|
431
|
-
var obj = EditorUtility.InstanceIDToObject(Convert.ToInt32(refId));
|
|
432
|
-
prop.objectReferenceValue = obj;
|
|
433
|
-
}
|
|
418
|
+
prop.objectReferenceValue = ObjectReferenceResolver.Resolve(value, prop.displayName);
|
|
419
|
+
if (value != null && prop.objectReferenceValue == null)
|
|
420
|
+
throw new ArgumentException($"Unable to assign object reference to '{prop.displayName}'");
|
|
434
421
|
break;
|
|
435
422
|
case SerializedPropertyType.LayerMask:
|
|
436
423
|
prop.intValue = Convert.ToInt32(value);
|
|
@@ -457,12 +444,7 @@ namespace UCP.Bridge
|
|
|
457
444
|
if (value is Color c)
|
|
458
445
|
return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
|
|
459
446
|
if (value is UnityEngine.Object uObj)
|
|
460
|
-
return
|
|
461
|
-
{
|
|
462
|
-
["instanceId"] = uObj.GetInstanceID(),
|
|
463
|
-
["name"] = uObj.name,
|
|
464
|
-
["type"] = uObj.GetType().Name
|
|
465
|
-
};
|
|
447
|
+
return ObjectReferenceResolver.Serialize(uObj);
|
|
466
448
|
return value.ToString();
|
|
467
449
|
}
|
|
468
450
|
|
|
@@ -40,6 +40,8 @@ namespace UCP.Bridge
|
|
|
40
40
|
throw new System.ArgumentException("Missing 'path' parameter");
|
|
41
41
|
|
|
42
42
|
var path = pathObj.ToString();
|
|
43
|
+
var saveDirtyScenes = GetBoolParam(p, "saveDirtyScenes", true);
|
|
44
|
+
var discardUntitled = GetBoolParam(p, "discardUntitled", true);
|
|
43
45
|
|
|
44
46
|
if (EditorApplication.isPlaying)
|
|
45
47
|
{
|
|
@@ -47,12 +49,51 @@ namespace UCP.Bridge
|
|
|
47
49
|
}
|
|
48
50
|
else
|
|
49
51
|
{
|
|
50
|
-
|
|
52
|
+
SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
|
|
53
|
+
EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
return new { status = "ok", loaded = path };
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
private static bool GetBoolParam(Dictionary<string, object> parameters, string key, bool defaultValue)
|
|
60
|
+
{
|
|
61
|
+
if (parameters != null && parameters.TryGetValue(key, out var valueObj) && valueObj is bool value)
|
|
62
|
+
return value;
|
|
63
|
+
|
|
64
|
+
return defaultValue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private static void SaveDirtyScenesIfRequested(bool saveDirtyScenes, bool discardUntitled)
|
|
68
|
+
{
|
|
69
|
+
if (!saveDirtyScenes)
|
|
70
|
+
return;
|
|
71
|
+
|
|
72
|
+
var requiresUntitledDiscard = false;
|
|
73
|
+
|
|
74
|
+
for (var index = 0; index < SceneManager.sceneCount; index++)
|
|
75
|
+
{
|
|
76
|
+
var scene = SceneManager.GetSceneAt(index);
|
|
77
|
+
if (!scene.isLoaded || !scene.isDirty)
|
|
78
|
+
continue;
|
|
79
|
+
|
|
80
|
+
if (string.IsNullOrEmpty(scene.path))
|
|
81
|
+
{
|
|
82
|
+
if (!discardUntitled)
|
|
83
|
+
throw new System.InvalidOperationException("Dirty untitled scene cannot be auto-saved. Retry with discardUntitled=true.");
|
|
84
|
+
|
|
85
|
+
requiresUntitledDiscard = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!EditorSceneManager.SaveScene(scene))
|
|
90
|
+
throw new System.InvalidOperationException($"Failed to auto-save dirty scene: {scene.path}");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (requiresUntitledDiscard)
|
|
94
|
+
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
|
95
|
+
}
|
|
96
|
+
|
|
56
97
|
private static object HandleActive(string paramsJson)
|
|
57
98
|
{
|
|
58
99
|
var scene = SceneManager.GetActiveScene();
|
|
@@ -55,7 +55,21 @@ namespace UCP.Bridge
|
|
|
55
55
|
executionSettings.filters[0].testNames = new[] { filter };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
var shouldWaitForPlayModeExit =
|
|
59
|
+
testMode == TestMode.EditMode &&
|
|
60
|
+
(EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode);
|
|
61
|
+
|
|
62
|
+
if (shouldWaitForPlayModeExit)
|
|
63
|
+
{
|
|
64
|
+
if (EditorApplication.isPlaying)
|
|
65
|
+
EditorApplication.isPlaying = false;
|
|
66
|
+
|
|
67
|
+
ExecuteWhenReady(executionSettings, testMode, EditorApplication.timeSinceStartup + 30.0);
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
{
|
|
71
|
+
s_api.Execute(executionSettings);
|
|
72
|
+
}
|
|
59
73
|
|
|
60
74
|
// Tests run asynchronously in Unity. We return immediately with a pending status.
|
|
61
75
|
// Results will be sent as notifications when complete.
|
|
@@ -63,7 +77,53 @@ namespace UCP.Bridge
|
|
|
63
77
|
{
|
|
64
78
|
["status"] = "started",
|
|
65
79
|
["mode"] = mode,
|
|
66
|
-
["message"] =
|
|
80
|
+
["message"] = shouldWaitForPlayModeExit
|
|
81
|
+
? "Edit-mode tests queued. Waiting for Unity to exit play mode before starting."
|
|
82
|
+
: "Tests started. Results will arrive as notifications."
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private static void ExecuteWhenReady(ExecutionSettings settings, TestMode mode, double deadline)
|
|
87
|
+
{
|
|
88
|
+
EditorApplication.delayCall += () =>
|
|
89
|
+
{
|
|
90
|
+
var stillInPlayMode = EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode;
|
|
91
|
+
if (mode == TestMode.EditMode && stillInPlayMode)
|
|
92
|
+
{
|
|
93
|
+
if (EditorApplication.timeSinceStartup > deadline)
|
|
94
|
+
{
|
|
95
|
+
BridgeServer.BroadcastNotification("tests/result", new Dictionary<string, object>
|
|
96
|
+
{
|
|
97
|
+
["summary"] = new Dictionary<string, object>
|
|
98
|
+
{
|
|
99
|
+
["total"] = 1,
|
|
100
|
+
["passed"] = 0,
|
|
101
|
+
["failed"] = 1,
|
|
102
|
+
["skipped"] = 0,
|
|
103
|
+
["duration"] = 0.0
|
|
104
|
+
},
|
|
105
|
+
["tests"] = new List<object>
|
|
106
|
+
{
|
|
107
|
+
new Dictionary<string, object>
|
|
108
|
+
{
|
|
109
|
+
["name"] = "UCP.Bridge.Tests.PlayModeExitGuard",
|
|
110
|
+
["status"] = "failed",
|
|
111
|
+
["duration"] = 0.0,
|
|
112
|
+
["message"] = "Timed out waiting for Unity to exit play mode before running edit-mode tests."
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (s_api != null && s_collector != null)
|
|
118
|
+
s_api.UnregisterCallbacks(s_collector);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ExecuteWhenReady(settings, mode, deadline);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
s_api.Execute(settings);
|
|
67
127
|
};
|
|
68
128
|
}
|
|
69
129
|
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
using System;
|
|
2
|
+
using System.Collections;
|
|
2
3
|
using System.Collections.Generic;
|
|
4
|
+
using System.Text.RegularExpressions;
|
|
3
5
|
using NUnit.Framework;
|
|
4
6
|
using UnityEditor;
|
|
5
7
|
using UnityEditor.SceneManagement;
|
|
6
8
|
using UnityEngine;
|
|
9
|
+
using UnityEngine.TestTools;
|
|
7
10
|
|
|
8
11
|
namespace UCP.Bridge.Tests
|
|
9
12
|
{
|
|
10
13
|
public class ControllerSmokeTests
|
|
11
14
|
{
|
|
12
15
|
private const string TempAssetPath = "Assets/UcpControllerSmoke.asset";
|
|
16
|
+
private const string TempReferenceAssetPath = "Assets/UcpControllerReference.asset";
|
|
17
|
+
private const string TempPrefabPath = "Assets/UcpControllerSmoke.prefab";
|
|
18
|
+
private const string TempMaterialPath = "Assets/UcpControllerSmoke.mat";
|
|
19
|
+
private const string TempTextPath = "Assets/UcpControllerSmoke.txt";
|
|
13
20
|
|
|
14
21
|
private CommandRouter _router;
|
|
15
22
|
|
|
@@ -20,8 +27,19 @@ namespace UCP.Bridge.Tests
|
|
|
20
27
|
SnapshotController.Register(_router);
|
|
21
28
|
AssetController.Register(_router);
|
|
22
29
|
LogsController.Register(_router);
|
|
30
|
+
HierarchyController.Register(_router);
|
|
31
|
+
PropertyController.Register(_router);
|
|
32
|
+
FileController.Register(_router);
|
|
33
|
+
MaterialController.Register(_router);
|
|
34
|
+
PrefabController.Register(_router);
|
|
35
|
+
BuildController.Register(_router);
|
|
36
|
+
EditorSettingsController.Register(_router);
|
|
23
37
|
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
|
24
38
|
DeleteTempAsset();
|
|
39
|
+
DeleteTempReferenceAsset();
|
|
40
|
+
DeleteTempPrefab();
|
|
41
|
+
DeleteTempMaterial();
|
|
42
|
+
DeleteTempTextFile();
|
|
25
43
|
LogsController.ClearHistoryForTests();
|
|
26
44
|
}
|
|
27
45
|
|
|
@@ -29,6 +47,10 @@ namespace UCP.Bridge.Tests
|
|
|
29
47
|
public void TearDown()
|
|
30
48
|
{
|
|
31
49
|
DeleteTempAsset();
|
|
50
|
+
DeleteTempReferenceAsset();
|
|
51
|
+
DeleteTempPrefab();
|
|
52
|
+
DeleteTempMaterial();
|
|
53
|
+
DeleteTempTextFile();
|
|
32
54
|
LogsController.ClearHistoryForTests();
|
|
33
55
|
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
|
34
56
|
}
|
|
@@ -76,17 +98,18 @@ namespace UCP.Bridge.Tests
|
|
|
76
98
|
var response = _router.Dispatch(
|
|
77
99
|
"asset/search",
|
|
78
100
|
1,
|
|
79
|
-
"{\"
|
|
101
|
+
"{\"name\":\"SmokeNested\",\"path\":\"Assets\",\"maxResults\":10}"
|
|
80
102
|
);
|
|
81
103
|
|
|
82
104
|
Assert.That(response.error, Is.Null);
|
|
83
105
|
|
|
84
106
|
var result = (Dictionary<string, object>)response.result;
|
|
85
|
-
Assert.That(Convert.ToInt32(result["total"]), Is.
|
|
86
|
-
Assert.That(Convert.ToInt32(result["returned"]), Is.
|
|
107
|
+
Assert.That(Convert.ToInt32(result["total"]), Is.GreaterThanOrEqualTo(1));
|
|
108
|
+
Assert.That(Convert.ToInt32(result["returned"]), Is.GreaterThanOrEqualTo(1));
|
|
87
109
|
|
|
88
110
|
var matches = (List<object>)result["results"];
|
|
89
|
-
var match = (
|
|
111
|
+
var match = FindAssetMatch(matches, TempAssetPath, "SmokeNested");
|
|
112
|
+
Assert.That(match, Is.Not.Null);
|
|
90
113
|
Assert.That(match["path"], Is.EqualTo(TempAssetPath));
|
|
91
114
|
Assert.That(match["type"], Is.EqualTo("SearchNestedAsset"));
|
|
92
115
|
Assert.That(match["name"], Is.EqualTo("SmokeNested"));
|
|
@@ -94,7 +117,7 @@ namespace UCP.Bridge.Tests
|
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
[Test]
|
|
97
|
-
public void
|
|
120
|
+
public void LogsTail_ReturnsRequestedBufferedCount()
|
|
98
121
|
{
|
|
99
122
|
for (var index = 0; index < 12; index++)
|
|
100
123
|
LogsController.RecordTestLog("info", $"log {index}");
|
|
@@ -105,8 +128,8 @@ namespace UCP.Bridge.Tests
|
|
|
105
128
|
|
|
106
129
|
var result = (Dictionary<string, object>)response.result;
|
|
107
130
|
Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(12));
|
|
108
|
-
Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(
|
|
109
|
-
Assert.That(Convert.ToBoolean(result["truncated"]), Is.
|
|
131
|
+
Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(12));
|
|
132
|
+
Assert.That(Convert.ToBoolean(result["truncated"]), Is.False);
|
|
110
133
|
|
|
111
134
|
var logs = (List<object>)result["logs"];
|
|
112
135
|
var first = (Dictionary<string, object>)logs[0];
|
|
@@ -114,6 +137,23 @@ namespace UCP.Bridge.Tests
|
|
|
114
137
|
Assert.That(first.ContainsKey("messagePreview"), Is.True);
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
[Test]
|
|
141
|
+
public void LogsSearch_FiltersBeforeApplyingCount()
|
|
142
|
+
{
|
|
143
|
+
LogsController.RecordTestLog("warning", "Target failed once");
|
|
144
|
+
for (var index = 0; index < 8; index++)
|
|
145
|
+
LogsController.RecordTestLog("info", $"Noise {index}");
|
|
146
|
+
|
|
147
|
+
var response = _router.Dispatch("logs/search", 1, "{\"pattern\":\"Target\",\"count\":1}");
|
|
148
|
+
|
|
149
|
+
Assert.That(response.error, Is.Null);
|
|
150
|
+
|
|
151
|
+
var result = (Dictionary<string, object>)response.result;
|
|
152
|
+
Assert.That(Convert.ToInt32(result["total"]), Is.EqualTo(1));
|
|
153
|
+
Assert.That(Convert.ToInt32(result["returned"]), Is.EqualTo(1));
|
|
154
|
+
Assert.That(Convert.ToBoolean(result["truncated"]), Is.False);
|
|
155
|
+
}
|
|
156
|
+
|
|
117
157
|
[Test]
|
|
118
158
|
public void LogsSearch_UsesRegexAgainstBufferedHistory()
|
|
119
159
|
{
|
|
@@ -174,6 +214,278 @@ namespace UCP.Bridge.Tests
|
|
|
174
214
|
Assert.That(result["stackTrace"], Is.EqualTo("stack line 1\nstack line 2"));
|
|
175
215
|
}
|
|
176
216
|
|
|
217
|
+
[Test]
|
|
218
|
+
public void ObjectLifecycle_CreateMutateAndDelete_WorksEndToEnd()
|
|
219
|
+
{
|
|
220
|
+
var create = _router.Dispatch("object/create", 1, "{\"name\":\"SmokeObject\"}");
|
|
221
|
+
Assert.That(create.error, Is.Null);
|
|
222
|
+
|
|
223
|
+
var createResult = (Dictionary<string, object>)create.result;
|
|
224
|
+
var instanceId = Convert.ToInt32(createResult["instanceId"]);
|
|
225
|
+
|
|
226
|
+
var rename = _router.Dispatch("object/set-name", 1, "{\"instanceId\":" + instanceId + ",\"name\":\"RenamedSmoke\"}");
|
|
227
|
+
Assert.That(rename.error, Is.Null);
|
|
228
|
+
|
|
229
|
+
var deactivate = _router.Dispatch("object/set-active", 1, "{\"instanceId\":" + instanceId + ",\"active\":false}");
|
|
230
|
+
Assert.That(deactivate.error, Is.Null);
|
|
231
|
+
var deactivateResult = (Dictionary<string, object>)deactivate.result;
|
|
232
|
+
Assert.That(Convert.ToBoolean(deactivateResult["active"]), Is.False);
|
|
233
|
+
|
|
234
|
+
var activate = _router.Dispatch("object/set-active", 1, "{\"instanceId\":" + instanceId + ",\"active\":true}");
|
|
235
|
+
Assert.That(activate.error, Is.Null);
|
|
236
|
+
|
|
237
|
+
var addComponent = _router.Dispatch("object/add-component", 1, "{\"instanceId\":" + instanceId + ",\"type\":\"BoxCollider\"}");
|
|
238
|
+
Assert.That(addComponent.error, Is.Null);
|
|
239
|
+
|
|
240
|
+
var setPosition = _router.Dispatch(
|
|
241
|
+
"object/set-property",
|
|
242
|
+
1,
|
|
243
|
+
"{\"instanceId\":" + instanceId + ",\"component\":\"Transform\",\"property\":\"m_LocalPosition\",\"value\":[1,2,3]}"
|
|
244
|
+
);
|
|
245
|
+
Assert.That(setPosition.error, Is.Null);
|
|
246
|
+
|
|
247
|
+
var getPosition = _router.Dispatch(
|
|
248
|
+
"object/get-property",
|
|
249
|
+
1,
|
|
250
|
+
"{\"instanceId\":" + instanceId + ",\"component\":\"Transform\",\"property\":\"m_LocalPosition\"}"
|
|
251
|
+
);
|
|
252
|
+
Assert.That(getPosition.error, Is.Null);
|
|
253
|
+
|
|
254
|
+
var updated = EditorUtility.InstanceIDToObject(instanceId) as GameObject;
|
|
255
|
+
Assert.That(updated, Is.Not.Null);
|
|
256
|
+
var localPosition = updated.transform.localPosition;
|
|
257
|
+
Assert.That(localPosition.x, Is.EqualTo(1f).Within(0.001f));
|
|
258
|
+
Assert.That(localPosition.y, Is.EqualTo(2f).Within(0.001f));
|
|
259
|
+
Assert.That(localPosition.z, Is.EqualTo(3f).Within(0.001f));
|
|
260
|
+
|
|
261
|
+
var removeComponent = _router.Dispatch("object/remove-component", 1, "{\"instanceId\":" + instanceId + ",\"type\":\"BoxCollider\"}");
|
|
262
|
+
Assert.That(removeComponent.error, Is.Null);
|
|
263
|
+
|
|
264
|
+
var delete = _router.Dispatch("object/delete", 1, "{\"instanceId\":" + instanceId + "}");
|
|
265
|
+
Assert.That(delete.error, Is.Null);
|
|
266
|
+
Assert.That(EditorUtility.InstanceIDToObject(instanceId), Is.Null);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
[Test]
|
|
270
|
+
public void ObjectSetProperty_AssignsObjectReferenceByAssetPath()
|
|
271
|
+
{
|
|
272
|
+
var referencedAsset = ScriptableObject.CreateInstance<SearchRootAsset>();
|
|
273
|
+
referencedAsset.name = "ReferenceAsset";
|
|
274
|
+
AssetDatabase.CreateAsset(referencedAsset, TempReferenceAssetPath);
|
|
275
|
+
AssetDatabase.SaveAssets();
|
|
276
|
+
|
|
277
|
+
var go = new GameObject("ReferenceCarrier");
|
|
278
|
+
var component = go.AddComponent<ReferenceComponent>();
|
|
279
|
+
|
|
280
|
+
var response = _router.Dispatch(
|
|
281
|
+
"object/set-property",
|
|
282
|
+
1,
|
|
283
|
+
"{\"instanceId\":" + go.GetInstanceID() + ",\"component\":\"ReferenceComponent\",\"property\":\"referenceAsset\",\"value\":{\"path\":\"" + TempReferenceAssetPath + "\"}}"
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
Assert.That(response.error, Is.Null);
|
|
287
|
+
Assert.That(component.referenceAsset, Is.Not.Null);
|
|
288
|
+
Assert.That(AssetDatabase.GetAssetPath(component.referenceAsset), Is.EqualTo(TempReferenceAssetPath));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
[Test]
|
|
292
|
+
public void ObjectSetProperty_RejectsUnknownObjectReference()
|
|
293
|
+
{
|
|
294
|
+
var go = new GameObject("ReferenceCarrier");
|
|
295
|
+
go.AddComponent<ReferenceComponent>();
|
|
296
|
+
|
|
297
|
+
var response = _router.Dispatch(
|
|
298
|
+
"object/set-property",
|
|
299
|
+
1,
|
|
300
|
+
"{\"instanceId\":" + go.GetInstanceID() + ",\"component\":\"ReferenceComponent\",\"property\":\"referenceAsset\",\"value\":{\"path\":\"Assets/Missing.asset\"}}"
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
Assert.That(response.error, Is.Not.Null);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
[Test]
|
|
307
|
+
public void AssetWriteBatch_UpdatesMultipleFieldsIncludingObjectReference()
|
|
308
|
+
{
|
|
309
|
+
var reference = ScriptableObject.CreateInstance<SearchRootAsset>();
|
|
310
|
+
reference.name = "ReferenceAsset";
|
|
311
|
+
AssetDatabase.CreateAsset(reference, TempReferenceAssetPath);
|
|
312
|
+
|
|
313
|
+
var asset = ScriptableObject.CreateInstance<BatchWritableAsset>();
|
|
314
|
+
asset.maxPlayers = 2;
|
|
315
|
+
asset.spawnDelay = 5f;
|
|
316
|
+
AssetDatabase.CreateAsset(asset, TempAssetPath);
|
|
317
|
+
AssetDatabase.SaveAssets();
|
|
318
|
+
|
|
319
|
+
var response = _router.Dispatch(
|
|
320
|
+
"asset/write-batch",
|
|
321
|
+
1,
|
|
322
|
+
"{\"path\":\"" + TempAssetPath + "\",\"values\":{\"maxPlayers\":8,\"spawnDelay\":1.5,\"referenceAsset\":{\"path\":\"" + TempReferenceAssetPath + "\"}}}"
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
Assert.That(response.error, Is.Null);
|
|
326
|
+
|
|
327
|
+
var reloaded = AssetDatabase.LoadAssetAtPath<BatchWritableAsset>(TempAssetPath);
|
|
328
|
+
Assert.That(reloaded.maxPlayers, Is.EqualTo(8));
|
|
329
|
+
Assert.That(reloaded.spawnDelay, Is.EqualTo(1.5f).Within(0.001f));
|
|
330
|
+
Assert.That(AssetDatabase.GetAssetPath(reloaded.referenceAsset), Is.EqualTo(TempReferenceAssetPath));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
[Test]
|
|
334
|
+
public void FileController_WritePatchRead_AndRejectsPathTraversal()
|
|
335
|
+
{
|
|
336
|
+
var write = _router.Dispatch("file/write", 1, "{\"path\":\"Assets/UcpControllerSmoke.txt\",\"content\":\"hello smoke\"}");
|
|
337
|
+
Assert.That(write.error, Is.Null);
|
|
338
|
+
|
|
339
|
+
var patch = _router.Dispatch(
|
|
340
|
+
"file/patch",
|
|
341
|
+
1,
|
|
342
|
+
"{\"path\":\"Assets/UcpControllerSmoke.txt\",\"patch\":{\"find\":\"smoke\",\"replace\":\"patched\"}}"
|
|
343
|
+
);
|
|
344
|
+
Assert.That(patch.error, Is.Null);
|
|
345
|
+
|
|
346
|
+
var read = _router.Dispatch("file/read", 1, "{\"path\":\"Assets/UcpControllerSmoke.txt\"}");
|
|
347
|
+
Assert.That(read.error, Is.Null);
|
|
348
|
+
var readResult = (Dictionary<string, object>)read.result;
|
|
349
|
+
Assert.That(readResult["content"].ToString(), Is.EqualTo("hello patched"));
|
|
350
|
+
|
|
351
|
+
LogAssert.Expect(LogType.Error, new Regex("\\[UCP\\] Error handling 'file/read':"));
|
|
352
|
+
var traversal = _router.Dispatch("file/read", 1, "{\"path\":\"../outside.txt\"}");
|
|
353
|
+
Assert.That(traversal.error, Is.Not.Null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
[Test]
|
|
357
|
+
public void MaterialController_SetAndGetFloatProperty_RoundTrips()
|
|
358
|
+
{
|
|
359
|
+
var shader = Shader.Find("Standard") ?? Shader.Find("Universal Render Pipeline/Lit");
|
|
360
|
+
Assert.That(shader, Is.Not.Null);
|
|
361
|
+
|
|
362
|
+
var propertyName = FindFirstFloatOrRangeProperty(shader);
|
|
363
|
+
Assert.That(propertyName, Is.Not.Null.And.Not.Empty);
|
|
364
|
+
|
|
365
|
+
var material = new Material(shader) { name = "UcpControllerSmokeMat" };
|
|
366
|
+
AssetDatabase.CreateAsset(material, TempMaterialPath);
|
|
367
|
+
AssetDatabase.SaveAssets();
|
|
368
|
+
|
|
369
|
+
var set = _router.Dispatch(
|
|
370
|
+
"material/set-property",
|
|
371
|
+
1,
|
|
372
|
+
"{\"path\":\"Assets/UcpControllerSmoke.mat\",\"property\":\"" + propertyName + "\",\"value\":0.42}"
|
|
373
|
+
);
|
|
374
|
+
Assert.That(set.error, Is.Null);
|
|
375
|
+
|
|
376
|
+
var get = _router.Dispatch(
|
|
377
|
+
"material/get-property",
|
|
378
|
+
1,
|
|
379
|
+
"{\"path\":\"Assets/UcpControllerSmoke.mat\",\"property\":\"" + propertyName + "\"}"
|
|
380
|
+
);
|
|
381
|
+
Assert.That(get.error, Is.Null);
|
|
382
|
+
|
|
383
|
+
var getResult = (Dictionary<string, object>)get.result;
|
|
384
|
+
var value = Convert.ToSingle(getResult["value"]);
|
|
385
|
+
Assert.That(value, Is.EqualTo(0.42f).Within(0.001f));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
[Test]
|
|
389
|
+
public void PrefabController_CreateStatusOverridesAndUnpack_Works()
|
|
390
|
+
{
|
|
391
|
+
var create = _router.Dispatch("object/create", 1, "{\"name\":\"PrefabSource\"}");
|
|
392
|
+
Assert.That(create.error, Is.Null);
|
|
393
|
+
var sourceId = Convert.ToInt32(((Dictionary<string, object>)create.result)["instanceId"]);
|
|
394
|
+
|
|
395
|
+
var createPrefab = _router.Dispatch(
|
|
396
|
+
"prefab/create",
|
|
397
|
+
1,
|
|
398
|
+
"{\"instanceId\":" + sourceId + ",\"path\":\"Assets/UcpControllerSmoke.prefab\"}"
|
|
399
|
+
);
|
|
400
|
+
Assert.That(createPrefab.error, Is.Null);
|
|
401
|
+
|
|
402
|
+
var instantiate = _router.Dispatch(
|
|
403
|
+
"object/instantiate",
|
|
404
|
+
1,
|
|
405
|
+
"{\"prefab\":\"Assets/UcpControllerSmoke.prefab\",\"name\":\"PrefabInstance\"}"
|
|
406
|
+
);
|
|
407
|
+
Assert.That(instantiate.error, Is.Null);
|
|
408
|
+
var instanceId = Convert.ToInt32(((Dictionary<string, object>)instantiate.result)["instanceId"]);
|
|
409
|
+
|
|
410
|
+
var status = _router.Dispatch("prefab/status", 1, "{\"instanceId\":" + instanceId + "}");
|
|
411
|
+
Assert.That(status.error, Is.Null);
|
|
412
|
+
var statusResult = (Dictionary<string, object>)status.result;
|
|
413
|
+
Assert.That(Convert.ToBoolean(statusResult["isInstance"]), Is.True);
|
|
414
|
+
|
|
415
|
+
var mutate = _router.Dispatch(
|
|
416
|
+
"object/set-property",
|
|
417
|
+
1,
|
|
418
|
+
"{\"instanceId\":" + instanceId + ",\"component\":\"Transform\",\"property\":\"m_LocalPosition\",\"value\":[2,0,0]}"
|
|
419
|
+
);
|
|
420
|
+
Assert.That(mutate.error, Is.Null);
|
|
421
|
+
|
|
422
|
+
var overrides = _router.Dispatch("prefab/overrides", 1, "{\"instanceId\":" + instanceId + "}");
|
|
423
|
+
Assert.That(overrides.error, Is.Null);
|
|
424
|
+
var overridesResult = (Dictionary<string, object>)overrides.result;
|
|
425
|
+
var modifications = (List<object>)overridesResult["propertyModifications"];
|
|
426
|
+
Assert.That(modifications.Count, Is.GreaterThanOrEqualTo(1));
|
|
427
|
+
|
|
428
|
+
var unpack = _router.Dispatch("prefab/unpack", 1, "{\"instanceId\":" + instanceId + "}");
|
|
429
|
+
Assert.That(unpack.error, Is.Null);
|
|
430
|
+
|
|
431
|
+
var unpackedStatus = _router.Dispatch("prefab/status", 1, "{\"instanceId\":" + instanceId + "}");
|
|
432
|
+
Assert.That(unpackedStatus.error, Is.Null);
|
|
433
|
+
var unpackedStatusResult = (Dictionary<string, object>)unpackedStatus.result;
|
|
434
|
+
Assert.That(Convert.ToBoolean(unpackedStatusResult["isInstance"]), Is.False);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
[Test]
|
|
438
|
+
public void SettingsAndBuildControllers_RoundTripWithoutSideEffects()
|
|
439
|
+
{
|
|
440
|
+
var playerSettings = _router.Dispatch("settings/player", 1, "{}");
|
|
441
|
+
Assert.That(playerSettings.error, Is.Null);
|
|
442
|
+
var settingsResult = (Dictionary<string, object>)playerSettings.result;
|
|
443
|
+
var originalProductName = settingsResult["productName"].ToString();
|
|
444
|
+
|
|
445
|
+
var setPlayer = _router.Dispatch(
|
|
446
|
+
"settings/player-set",
|
|
447
|
+
1,
|
|
448
|
+
"{\"key\":\"productName\",\"value\":\"UcpQaProduct\"}"
|
|
449
|
+
);
|
|
450
|
+
Assert.That(setPlayer.error, Is.Null);
|
|
451
|
+
|
|
452
|
+
var verifyPlayer = _router.Dispatch("settings/player", 1, "{}");
|
|
453
|
+
Assert.That(verifyPlayer.error, Is.Null);
|
|
454
|
+
var verifyResult = (Dictionary<string, object>)verifyPlayer.result;
|
|
455
|
+
Assert.That(verifyResult["productName"].ToString(), Is.EqualTo("UcpQaProduct"));
|
|
456
|
+
|
|
457
|
+
var restorePlayer = _router.Dispatch(
|
|
458
|
+
"settings/player-set",
|
|
459
|
+
1,
|
|
460
|
+
"{\"key\":\"productName\",\"value\":\"" + originalProductName + "\"}"
|
|
461
|
+
);
|
|
462
|
+
Assert.That(restorePlayer.error, Is.Null);
|
|
463
|
+
|
|
464
|
+
var scenes = _router.Dispatch("build/scenes", 1, "{}");
|
|
465
|
+
Assert.That(scenes.error, Is.Null);
|
|
466
|
+
var scenesResult = (Dictionary<string, object>)scenes.result;
|
|
467
|
+
var currentScenes = (List<object>)scenesResult["scenes"];
|
|
468
|
+
Assert.That(currentScenes.Count, Is.GreaterThanOrEqualTo(1));
|
|
469
|
+
|
|
470
|
+
var firstScene = (Dictionary<string, object>)currentScenes[0];
|
|
471
|
+
var firstScenePath = firstScene["path"].ToString();
|
|
472
|
+
|
|
473
|
+
var setScenes = _router.Dispatch(
|
|
474
|
+
"build/set-scenes",
|
|
475
|
+
1,
|
|
476
|
+
"{\"scenes\":[\"" + EscapeForJson(firstScenePath) + "\"]}"
|
|
477
|
+
);
|
|
478
|
+
Assert.That(setScenes.error, Is.Null);
|
|
479
|
+
|
|
480
|
+
var defines = _router.Dispatch("build/defines", 1, "{}");
|
|
481
|
+
Assert.That(defines.error, Is.Null);
|
|
482
|
+
var definesResult = (Dictionary<string, object>)defines.result;
|
|
483
|
+
var originalDefines = definesResult["defines"].ToString();
|
|
484
|
+
|
|
485
|
+
var setDefines = _router.Dispatch("build/set-defines", 1, "{\"defines\":\"" + EscapeForJson(originalDefines) + "\"}");
|
|
486
|
+
Assert.That(setDefines.error, Is.Null);
|
|
487
|
+
}
|
|
488
|
+
|
|
177
489
|
private static void DeleteTempAsset()
|
|
178
490
|
{
|
|
179
491
|
if (AssetDatabase.LoadMainAssetAtPath(TempAssetPath) != null)
|
|
@@ -183,6 +495,78 @@ namespace UCP.Bridge.Tests
|
|
|
183
495
|
}
|
|
184
496
|
}
|
|
185
497
|
|
|
498
|
+
private static void DeleteTempReferenceAsset()
|
|
499
|
+
{
|
|
500
|
+
if (AssetDatabase.LoadMainAssetAtPath(TempReferenceAssetPath) != null)
|
|
501
|
+
{
|
|
502
|
+
AssetDatabase.DeleteAsset(TempReferenceAssetPath);
|
|
503
|
+
AssetDatabase.SaveAssets();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private static void DeleteTempPrefab()
|
|
508
|
+
{
|
|
509
|
+
if (AssetDatabase.LoadMainAssetAtPath(TempPrefabPath) != null)
|
|
510
|
+
{
|
|
511
|
+
AssetDatabase.DeleteAsset(TempPrefabPath);
|
|
512
|
+
AssetDatabase.SaveAssets();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private static void DeleteTempMaterial()
|
|
517
|
+
{
|
|
518
|
+
if (AssetDatabase.LoadMainAssetAtPath(TempMaterialPath) != null)
|
|
519
|
+
{
|
|
520
|
+
AssetDatabase.DeleteAsset(TempMaterialPath);
|
|
521
|
+
AssetDatabase.SaveAssets();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private static void DeleteTempTextFile()
|
|
526
|
+
{
|
|
527
|
+
if (AssetDatabase.LoadMainAssetAtPath(TempTextPath) != null)
|
|
528
|
+
{
|
|
529
|
+
AssetDatabase.DeleteAsset(TempTextPath);
|
|
530
|
+
AssetDatabase.SaveAssets();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private static string FindFirstFloatOrRangeProperty(Shader shader)
|
|
535
|
+
{
|
|
536
|
+
for (var index = 0; index < shader.GetPropertyCount(); index++)
|
|
537
|
+
{
|
|
538
|
+
var propertyType = shader.GetPropertyType(index);
|
|
539
|
+
if (propertyType == UnityEngine.Rendering.ShaderPropertyType.Float
|
|
540
|
+
|| propertyType == UnityEngine.Rendering.ShaderPropertyType.Range)
|
|
541
|
+
{
|
|
542
|
+
return shader.GetPropertyName(index);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private static string EscapeForJson(string value)
|
|
550
|
+
{
|
|
551
|
+
return value
|
|
552
|
+
.Replace("\\", "\\\\")
|
|
553
|
+
.Replace("\"", "\\\"");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private static Dictionary<string, object> FindAssetMatch(List<object> matches, string expectedPath, string expectedName)
|
|
557
|
+
{
|
|
558
|
+
foreach (var entry in matches)
|
|
559
|
+
{
|
|
560
|
+
var match = (Dictionary<string, object>)entry;
|
|
561
|
+
var path = match.ContainsKey("path") ? match["path"].ToString() : string.Empty;
|
|
562
|
+
var name = match.ContainsKey("name") ? match["name"].ToString() : string.Empty;
|
|
563
|
+
if (path == expectedPath && name == expectedName)
|
|
564
|
+
return match;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
186
570
|
private sealed class SearchRootAsset : ScriptableObject
|
|
187
571
|
{
|
|
188
572
|
}
|
|
@@ -190,5 +574,17 @@ namespace UCP.Bridge.Tests
|
|
|
190
574
|
private sealed class SearchNestedAsset : ScriptableObject
|
|
191
575
|
{
|
|
192
576
|
}
|
|
577
|
+
|
|
578
|
+
private sealed class BatchWritableAsset : ScriptableObject
|
|
579
|
+
{
|
|
580
|
+
public int maxPlayers;
|
|
581
|
+
public float spawnDelay;
|
|
582
|
+
public SearchRootAsset referenceAsset;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private sealed class ReferenceComponent : MonoBehaviour
|
|
586
|
+
{
|
|
587
|
+
public SearchRootAsset referenceAsset;
|
|
588
|
+
}
|
|
193
589
|
}
|
|
194
590
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "com.ucp.bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"displayName": "Unity Control Protocol Bridge",
|
|
5
5
|
"description": "WebSocket bridge for programmatic Unity Editor control via CLI and AI agents.",
|
|
6
6
|
"unity": "2021.3",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"url": "https://github.com/mflRevan/unity-control-protocol.git"
|
|
22
22
|
},
|
|
23
23
|
"type": "tool",
|
|
24
|
-
"documentationUrl": "https://github.com/mflRevan/unity-control-protocol/blob/main/
|
|
24
|
+
"documentationUrl": "https://github.com/mflRevan/unity-control-protocol/blob/main/README.md",
|
|
25
25
|
"changelogUrl": "https://github.com/mflRevan/unity-control-protocol/blob/main/unity-package/com.ucp.bridge/CHANGELOG.md",
|
|
26
26
|
"dependencies": {}
|
|
27
27
|
}
|