@mflrevan/ucp 0.6.0 → 0.6.2

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.
@@ -46,6 +46,7 @@ namespace UCP.Bridge.Tests
46
46
  PlayModeController.Register(_router);
47
47
  ReferenceController.Register(_router);
48
48
  LogsController.Register(_router);
49
+ RecordingController.Register(_router);
49
50
  HierarchyController.Register(_router);
50
51
  ProfilerController.Register(_router);
51
52
  PropertyController.Register(_router);
@@ -72,6 +73,7 @@ namespace UCP.Bridge.Tests
72
73
  DeleteTempLocalPackage();
73
74
  RemoveTempLocalPackageDependencyIfPresent();
74
75
  LogsController.ClearHistoryForTests();
76
+ RecordingController.ResetForTests();
75
77
  AssetImportSupport.ClearTestState();
76
78
  Profiler.enabled = false;
77
79
  Profiler.enableBinaryLog = false;
@@ -97,6 +99,7 @@ namespace UCP.Bridge.Tests
97
99
  DeleteTempLocalPackage();
98
100
  RemoveTempLocalPackageDependencyIfPresent();
99
101
  LogsController.ClearHistoryForTests();
102
+ RecordingController.ResetForTests();
100
103
  AssetImportSupport.ClearTestState();
101
104
  Profiler.enabled = false;
102
105
  Profiler.enableBinaryLog = false;
@@ -123,6 +126,115 @@ namespace UCP.Bridge.Tests
123
126
  Assert.That(Convert.ToBoolean(capabilities["sessionControl"]), Is.True);
124
127
  }
125
128
 
129
+ [Test]
130
+ public void RecordingController_RegistersLifecycleMethodsAndReportsIdle()
131
+ {
132
+ Assert.That(_router.HasMethod("record/start"), Is.True);
133
+ Assert.That(_router.HasMethod("record/stop"), Is.True);
134
+ Assert.That(_router.HasMethod("record/status"), Is.True);
135
+ Assert.That(_router.HasMethod("record/arm"), Is.True);
136
+ Assert.That(_router.HasMethod("record/signal"), Is.True);
137
+
138
+ var response = _router.Dispatch("record/status", 1, "{}");
139
+ Assert.That(response.error, Is.Null);
140
+ var result = (Dictionary<string, object>)response.result;
141
+ Assert.That(result["state"], Is.EqualTo("idle"));
142
+ }
143
+
144
+ [Test]
145
+ public void RecordingController_ResolvesEvenAspectPreservingDimensions()
146
+ {
147
+ Assert.That(RecordingController.ResolveDimensionsForTests(16f / 9f, 960, null, null),
148
+ Is.EqualTo((960, 540)));
149
+ Assert.That(RecordingController.ResolveDimensionsForTests(9f / 16f, 960, null, null),
150
+ Is.EqualTo((540, 960)));
151
+ Assert.That(RecordingController.ResolveDimensionsForTests(4f / 3f, 960, 640, null),
152
+ Is.EqualTo((640, 480)));
153
+ Assert.That(RecordingController.ResolveDimensionsForTests(4f / 3f, 960, null, 600),
154
+ Is.EqualTo((800, 600)));
155
+ }
156
+
157
+ [Test]
158
+ public void RecordingController_ArmsAndMatchesNamedSignal()
159
+ {
160
+ var response = _router.Dispatch(
161
+ "record/arm",
162
+ 1,
163
+ "{\"trigger\":\"signal:not-this-one\",\"duration\":1,\"timeout\":5}");
164
+ Assert.That(response.error, Is.Null);
165
+
166
+ var signal = _router.Dispatch("record/signal", 2, "{\"name\":\"different\"}");
167
+ Assert.That(signal.error, Is.Null);
168
+ var result = (Dictionary<string, object>)signal.result;
169
+ Assert.That(Convert.ToBoolean(result["matched"]), Is.False);
170
+ Assert.That(result["state"], Is.EqualTo("armed"));
171
+
172
+ var stop = _router.Dispatch("record/stop", 3, "{}");
173
+ Assert.That(stop.error, Is.Null);
174
+ var stopped = (Dictionary<string, object>)stop.result;
175
+ Assert.That(stopped["state"], Is.EqualTo("idle"));
176
+ }
177
+
178
+ [Test]
179
+ public void GetProperty_ReturnsCompositeValuesRatherThanTypeNames()
180
+ {
181
+ // Regression: when SerializedObject.FindProperty resolves a name, the value it returns
182
+ // is already JSON-shaped. That result used to be converted a second time, match no
183
+ // case, and fall through to value.ToString() -- so a Vector3 arrived as
184
+ // "System.Collections.Generic.List`1[System.Object]". Querying the serialized name
185
+ // (m_LocalPosition) is what forces the FindProperty path; the public alias (position)
186
+ // has no serialized entry and takes the reflection fallback instead, which is why that
187
+ // one never looked broken.
188
+ var go = new GameObject("UcpPropertyProbe");
189
+ try
190
+ {
191
+ go.transform.localPosition = new Vector3(1.5f, 2.5f, 3.5f);
192
+ var id = go.GetInstanceID();
193
+
194
+ var serialized = _router.Dispatch("object/get-property", 1, "{\"instanceId\":ID,\"component\":\"Transform\",\"property\":\"m_LocalPosition\"}".Replace("ID", id.ToString()));
195
+ Assert.That(serialized.error, Is.Null);
196
+ var serializedResult = (Dictionary<string, object>)serialized.result;
197
+ var localPosition = serializedResult["value"] as IList;
198
+ Assert.That(localPosition, Is.Not.Null,
199
+ "a serialized Vector3 must come back as an array, not a stringified type name");
200
+ Assert.That(localPosition.Count, Is.EqualTo(3));
201
+ Assert.That(Convert.ToDouble(localPosition[0]), Is.EqualTo(1.5d).Within(1e-4));
202
+ Assert.That(Convert.ToDouble(localPosition[2]), Is.EqualTo(3.5d).Within(1e-4));
203
+ Assert.That(serializedResult["type"], Is.EqualTo("Vector3"),
204
+ "type must describe the field, not the container it was shaped into");
205
+
206
+ // Reflection fallback: no serialized entry named "position".
207
+ var reflected = _router.Dispatch("object/get-property", 2, "{\"instanceId\":ID,\"component\":\"Transform\",\"property\":\"position\"}".Replace("ID", id.ToString()));
208
+ Assert.That(reflected.error, Is.Null);
209
+ var reflectedResult = (Dictionary<string, object>)reflected.result;
210
+ Assert.That(reflectedResult["value"] as IList, Is.Not.Null);
211
+ Assert.That(reflectedResult["type"], Is.EqualTo("Vector3"));
212
+ }
213
+ finally
214
+ {
215
+ UnityEngine.Object.DestroyImmediate(go);
216
+ }
217
+ }
218
+
219
+ [Test]
220
+ public void RecordingController_ValidatesSlowdownRange()
221
+ {
222
+ // --slowdown exists to raise effective temporal resolution for models that sample a
223
+ // clip at a fixed low rate, so an out-of-range factor must fail loudly rather than
224
+ // silently producing a file whose playback rate is meaningless.
225
+ var tooLarge = _router.Dispatch("record/arm", 1, "{\"trigger\":\"signal:sd\",\"duration\":1,\"timeout\":5,\"slowdown\":40}");
226
+ Assert.That(tooLarge.error, Is.Not.Null, "a slowdown above the supported range must be rejected");
227
+
228
+ var accepted = _router.Dispatch("record/arm", 2, "{\"trigger\":\"signal:sd\",\"duration\":1,\"timeout\":5,\"slowdown\":6}");
229
+ Assert.That(accepted.error, Is.Null);
230
+ var armed = (Dictionary<string, object>)accepted.result;
231
+ Assert.That(armed["state"], Is.EqualTo("armed"));
232
+
233
+ var stop = _router.Dispatch("record/stop", 3, "{}");
234
+ Assert.That(stop.error, Is.Null);
235
+ Assert.That(((Dictionary<string, object>)stop.result)["state"], Is.EqualTo("idle"));
236
+ }
237
+
126
238
  [Test]
127
239
  public void ProfilerSessionStartStop_TogglesProfilerState()
128
240
  {
@@ -307,10 +419,48 @@ namespace UCP.Bridge.Tests
307
419
  var status = _router.Dispatch("logs/status", 1, "{}");
308
420
  Assert.That(status.error, Is.Null);
309
421
 
310
- var result = (Dictionary<string, object>)status.result;
311
- var byLevel = (Dictionary<string, object>)result["byLevel"];
312
- Assert.That(Convert.ToInt32(byLevel["error"]), Is.EqualTo(0));
313
- Assert.That(Convert.ToInt32(byLevel["exception"]), Is.EqualTo(0));
422
+ Assert.That(status.result, Is.InstanceOf<Dictionary<string, object>>());
423
+
424
+ // Assert on the specific regression this test exists for -- `asset/search` loading
425
+ // `.unity` files through LoadAllAssetsAtPath, which makes Unity emit
426
+ // "Do not use ReadObjectThreaded on scene objects!" -- rather than on "zero errors of
427
+ // any kind". A blanket count also catches unrelated editor noise: on a cold Library the
428
+ // search pulls in lazy imports whose URP shader-fallback errors have nothing to do with
429
+ // this code path, which made the whole release matrix red on every Unity version.
430
+ var problems = BufferedProblems();
431
+ var threadedReadErrors = problems
432
+ .FindAll(entry => entry.Contains("ReadObjectThreaded"));
433
+
434
+ Assert.That(
435
+ threadedReadErrors,
436
+ Is.Empty,
437
+ () => "asset/search emitted threaded scene-read errors:" + NewLineIndent
438
+ + string.Join(NewLineIndent, threadedReadErrors));
439
+ }
440
+
441
+ private const string NewLineIndent = "\n ";
442
+
443
+ /// Buffered error/exception entries, as "[level] message" strings.
444
+ private List<string> BufferedProblems()
445
+ {
446
+ var lines = new List<string>();
447
+ var tail = _router.Dispatch("logs/tail", 1, "{\"count\":200}");
448
+ if (tail.error != null || tail.result is not Dictionary<string, object> payload)
449
+ return lines;
450
+
451
+ if (!payload.TryGetValue("logs", out var logsObj) || logsObj is not List<object> logs)
452
+ return lines;
453
+
454
+ foreach (var item in logs)
455
+ {
456
+ if (item is not Dictionary<string, object> entry) continue;
457
+ var level = entry.TryGetValue("level", out var l) ? l?.ToString() : null;
458
+ if (level != "error" && level != "exception") continue;
459
+ var message = entry.TryGetValue("messagePreview", out var m) ? m?.ToString() : "";
460
+ lines.Add($"[{level}] {message}");
461
+ }
462
+
463
+ return lines;
314
464
  }
315
465
 
316
466
  [Test]
@@ -1142,9 +1292,33 @@ namespace UCP.Bridge.Tests
1142
1292
  System.Convert.ToSingle(axisData[0]),
1143
1293
  System.Convert.ToSingle(axisData[1]),
1144
1294
  System.Convert.ToSingle(axisData[2]));
1145
- var actualForward = sceneView.camera.transform.forward;
1146
1295
  Assert.That(Vector3.Dot(returnedAxis.normalized, expectedDirection), Is.GreaterThan(0.98f));
1147
- Assert.That(Mathf.Abs(Vector3.Dot(actualForward.normalized, expectedDirection)), Is.GreaterThan(0.98f));
1296
+
1297
+ // Assert against the Scene view's own state and the reported pose, not
1298
+ // `sceneView.camera.transform`: that transform is only synced when the view repaints,
1299
+ // so in batch mode it still holds the pre-focus pose and this check used to fail.
1300
+ var viewForward = sceneView.rotation * Vector3.forward;
1301
+ Assert.That(Mathf.Abs(Vector3.Dot(viewForward.normalized, expectedDirection)), Is.GreaterThan(0.98f));
1302
+
1303
+ var eulerData = (List<object>)result["cameraRotationEuler"];
1304
+ var reportedForward = Quaternion.Euler(
1305
+ System.Convert.ToSingle(eulerData[0]),
1306
+ System.Convert.ToSingle(eulerData[1]),
1307
+ System.Convert.ToSingle(eulerData[2])) * Vector3.forward;
1308
+ Assert.That(
1309
+ Mathf.Abs(Vector3.Dot(reportedForward.normalized, expectedDirection)),
1310
+ Is.GreaterThan(0.98f),
1311
+ "scene/focus must report the pose it just applied, not the last rendered one");
1312
+
1313
+ // The reported camera must sit behind the pivot along its own forward axis.
1314
+ var positionData = (List<object>)result["cameraPosition"];
1315
+ var reportedPosition = new Vector3(
1316
+ System.Convert.ToSingle(positionData[0]),
1317
+ System.Convert.ToSingle(positionData[1]),
1318
+ System.Convert.ToSingle(positionData[2]));
1319
+ Assert.That(Vector3.Dot((sceneView.pivot - reportedPosition).normalized, reportedForward.normalized),
1320
+ Is.GreaterThan(0.98f));
1321
+
1148
1322
  Assert.That(Vector3.Distance(sceneView.pivot, cube.transform.position), Is.LessThan(2f));
1149
1323
  }
1150
1324
 
@@ -1585,4 +1759,5 @@ namespace UCP.Bridge.Tests
1585
1759
  public SearchRootAsset referenceAsset;
1586
1760
  }
1587
1761
  }
1762
+
1588
1763
  }
@@ -0,0 +1,221 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using NUnit.Framework;
4
+ using UnityEngine;
5
+
6
+ namespace UCP.Bridge.Tests
7
+ {
8
+ /// <summary>
9
+ /// Regression coverage for the serializer that turns arbitrary <see cref="IUCPScript"/> return
10
+ /// values into JSON. The reflection walk used to be unbounded, so returning anything holding a
11
+ /// UnityEngine math struct (Vector3.normalized returns another Vector3, forever) overflowed the
12
+ /// stack -- which .NET cannot catch and which killed the editor process outright.
13
+ /// </summary>
14
+ public class MiniJsonSerializerTests
15
+ {
16
+ private static Dictionary<string, object> Roundtrip(object value)
17
+ {
18
+ var json = MiniJson.Serialize(new { value });
19
+ var parsed = MiniJson.Deserialize(json) as Dictionary<string, object>;
20
+ Assert.IsNotNull(parsed, $"Serializer produced unparseable JSON: {json}");
21
+ return parsed;
22
+ }
23
+
24
+ [Test]
25
+ public void SerializesVector3WithoutStackOverflow()
26
+ {
27
+ // The original crash repro: `return new { pos = Vector3.zero }` from a UCP script.
28
+ var json = MiniJson.Serialize(new { pos = Vector3.zero });
29
+
30
+ Assert.AreEqual("{\"pos\":{\"x\":0,\"y\":0,\"z\":0}}", json);
31
+ }
32
+
33
+ [Test]
34
+ public void SerializesUnityMathStructsAsPlainShapes()
35
+ {
36
+ Assert.AreEqual("{\"x\":1,\"y\":2}", MiniJson.Serialize(new Vector2(1f, 2f)));
37
+ Assert.AreEqual("{\"x\":1,\"y\":2,\"z\":3,\"w\":4}", MiniJson.Serialize(new Vector4(1f, 2f, 3f, 4f)));
38
+ Assert.AreEqual("{\"x\":0,\"y\":0,\"z\":0,\"w\":1}", MiniJson.Serialize(Quaternion.identity));
39
+ Assert.AreEqual("{\"r\":1,\"g\":0,\"b\":0,\"a\":1}", MiniJson.Serialize(Color.red));
40
+ Assert.AreEqual("{\"x\":1,\"y\":2,\"z\":3}", MiniJson.Serialize(new Vector3Int(1, 2, 3)));
41
+ Assert.AreEqual(
42
+ "{\"x\":1,\"y\":2,\"width\":3,\"height\":4}",
43
+ MiniJson.Serialize(new Rect(1f, 2f, 3f, 4f)));
44
+ Assert.AreEqual(
45
+ "{\"center\":{\"x\":0,\"y\":0,\"z\":0},\"size\":{\"x\":2,\"y\":2,\"z\":2}}",
46
+ MiniJson.Serialize(new Bounds(Vector3.zero, Vector3.one * 2f)));
47
+ }
48
+
49
+ [Test]
50
+ public void SerializesQuaternionNestedInAnonymousResult()
51
+ {
52
+ var parsed = Roundtrip(new { rot = Quaternion.Euler(0f, 90f, 0f), pos = Vector3.one });
53
+ Assert.IsInstanceOf<Dictionary<string, object>>(parsed["value"]);
54
+ }
55
+
56
+ [Test]
57
+ public void SerializesUnityObjectAsIdentityInsteadOfWalkingTheSceneGraph()
58
+ {
59
+ var go = new GameObject("MiniJsonProbe");
60
+ try
61
+ {
62
+ // GameObject.transform.gameObject is a cycle; walking it never terminates.
63
+ var parsed = Roundtrip(go);
64
+ var identity = (Dictionary<string, object>)parsed["value"];
65
+
66
+ Assert.AreEqual("MiniJsonProbe", identity["name"]);
67
+ Assert.AreEqual("GameObject", identity["type"]);
68
+ Assert.IsTrue(identity.ContainsKey("instanceId"));
69
+
70
+ // Components are the same story via Component.gameObject.
71
+ var componentJson = MiniJson.Serialize(go.transform);
72
+ Assert.IsTrue(componentJson.Contains("\"type\":\"Transform\""), componentJson);
73
+ }
74
+ finally
75
+ {
76
+ UnityEngine.Object.DestroyImmediate(go);
77
+ }
78
+ }
79
+
80
+ [Test]
81
+ public void DestroyedUnityObjectSerializesAsNull()
82
+ {
83
+ var go = new GameObject("MiniJsonDestroyed");
84
+ UnityEngine.Object.DestroyImmediate(go);
85
+
86
+ Assert.AreEqual("{\"value\":null}", MiniJson.Serialize(new { value = go }));
87
+ }
88
+
89
+ [Test]
90
+ public void ReferenceCyclesAreBrokenInsteadOfRecursingForever()
91
+ {
92
+ var a = new Node { Label = "a" };
93
+ var b = new Node { Label = "b", Next = a };
94
+ a.Next = b;
95
+
96
+ var json = MiniJson.Serialize(a);
97
+
98
+ Assert.IsTrue(json.Contains("<ucp:cycle>"), json);
99
+ Assert.IsNotNull(MiniJson.Deserialize(json));
100
+ }
101
+
102
+ [Test]
103
+ public void SelfReferencingDictionaryIsBroken()
104
+ {
105
+ var dict = new Dictionary<string, object> { ["name"] = "root" };
106
+ dict["self"] = dict;
107
+
108
+ var json = MiniJson.Serialize(dict);
109
+
110
+ Assert.IsTrue(json.Contains("<ucp:cycle>"), json);
111
+ Assert.IsNotNull(MiniJson.Deserialize(json));
112
+ }
113
+
114
+ [Test]
115
+ public void UnboundedComputedPropertyRecursionIsDepthCapped()
116
+ {
117
+ // Mirrors the Vector3.normalized shape for a type the serializer has no special case
118
+ // for: every read allocates a fresh instance, so reference tracking cannot help and
119
+ // only the depth cap prevents a stack overflow.
120
+ var json = MiniJson.Serialize(new Fractal());
121
+
122
+ Assert.IsTrue(json.Contains("<ucp:max-depth>"), json);
123
+ Assert.IsNotNull(MiniJson.Deserialize(json));
124
+ }
125
+
126
+ [Test]
127
+ public void ThrowingGetterDoesNotCorruptTheDocument()
128
+ {
129
+ var json = MiniJson.Serialize(new Explosive());
130
+
131
+ Assert.IsFalse(json.Contains("boom"), json);
132
+ var parsed = MiniJson.Deserialize(json) as Dictionary<string, object>;
133
+ Assert.IsNotNull(parsed, json);
134
+ Assert.AreEqual("ok", parsed["safe"]);
135
+ }
136
+
137
+ [Test]
138
+ public void NonFiniteFloatsSerializeAsNullRatherThanInvalidJson()
139
+ {
140
+ // Degenerate bounds and zero-length normalize hand out NaN routinely; raw NaN/Infinity
141
+ // are not valid JSON and made the whole response unparseable on the CLI side.
142
+ var json = MiniJson.Serialize(new { a = float.NaN, b = float.PositiveInfinity, c = double.NaN });
143
+
144
+ Assert.AreEqual("{\"a\":null,\"b\":null,\"c\":null}", json);
145
+ Assert.IsNotNull(MiniJson.Deserialize(json));
146
+ }
147
+
148
+ [Test]
149
+ public void UnsignedAndWideIntegersSerializeAsNumbers()
150
+ {
151
+ var json = MiniJson.Serialize(new { a = (uint)7, b = (ushort)8, c = (byte)9, d = 10UL });
152
+
153
+ Assert.AreEqual("{\"a\":7,\"b\":8,\"c\":9,\"d\":10}", json);
154
+ }
155
+
156
+ [Test]
157
+ public void NonListEnumerablesSerializeAsArrays()
158
+ {
159
+ var json = MiniJson.Serialize(new { items = new HashSet<int> { 1 } });
160
+
161
+ Assert.AreEqual("{\"items\":[1]}", json);
162
+ }
163
+
164
+ [Test]
165
+ public void EnumsStillSerializeAsIntegers()
166
+ {
167
+ Assert.AreEqual("{\"value\":2}", MiniJson.Serialize(new { value = SampleEnum.Two }));
168
+ }
169
+
170
+ [Test]
171
+ public void ParserRejectsTruncatedInputInsteadOfHanging()
172
+ {
173
+ // A truncated string literal used to spin the reader on end-of-input forever, wedging
174
+ // the editor's main thread.
175
+ Assert.Throws<FormatException>(() => MiniJson.Deserialize("{\"a\": \"unterminated"));
176
+ }
177
+
178
+ [Test]
179
+ public void ParserRejectsPathologicallyNestedInput()
180
+ {
181
+ var deep = new string('[', 1000);
182
+ Assert.Throws<FormatException>(() => MiniJson.Deserialize(deep));
183
+ }
184
+
185
+ [Test]
186
+ public void OrdinaryPayloadsAreUnchanged()
187
+ {
188
+ var json = MiniJson.Serialize(new Dictionary<string, object>
189
+ {
190
+ ["name"] = "cube",
191
+ ["active"] = true,
192
+ ["children"] = new List<object> { 1L, 2.5, null },
193
+ });
194
+
195
+ Assert.AreEqual("{\"name\":\"cube\",\"active\":true,\"children\":[1,2.5,null]}", json);
196
+ }
197
+
198
+ private enum SampleEnum
199
+ {
200
+ One = 1,
201
+ Two = 2,
202
+ }
203
+
204
+ private sealed class Node
205
+ {
206
+ public string Label;
207
+ public Node Next;
208
+ }
209
+
210
+ private sealed class Fractal
211
+ {
212
+ public Fractal Child => new Fractal();
213
+ }
214
+
215
+ private sealed class Explosive
216
+ {
217
+ public string Safe => "ok";
218
+ public string Boom => throw new InvalidOperationException("boom");
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 5082955dbcce78c4986000348e3a5d92
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.ucp.bridge",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mflrevan/ucp",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Unity Control Protocol - CLI for programmatic Unity Editor control",
5
5
  "license": "MIT",
6
6
  "repository": {