@mflrevan/ucp 0.6.2 → 0.6.3
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 +1 -1
- package/bridge/com.ucp.bridge/CHANGELOG.md +48 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +55 -6
- package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs +165 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Compatibility/UnityObjectCompat.cs +22 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/EditorController.cs +1 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +10 -10
- package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs +71 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +2 -2
- package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs +4 -4
- package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +3 -3
- package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +6 -6
- package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +7 -7
- package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs +8 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneChangeTracker.cs +3 -3
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +3 -3
- package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +5 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs +70 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +1 -1
- package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs +468 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs +554 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs +870 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs +496 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs +854 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs +374 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs +690 -0
- package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta +11 -0
- package/bridge/com.ucp.bridge/{Runtime.meta → Editor/Ui.meta} +1 -1
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +79 -4
- package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs +2 -2
- package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs +547 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs +253 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta +11 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs +587 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta +11 -0
- package/bridge/com.ucp.bridge/package.json +1 -1
- package/package.json +1 -1
- package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef +0 -14
- package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef.meta +0 -7
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
#if UNITY_6000_0_OR_NEWER
|
|
2
|
+
using System;
|
|
3
|
+
using System.Collections;
|
|
4
|
+
using System.Collections.Generic;
|
|
5
|
+
using System.Globalization;
|
|
6
|
+
using System.IO;
|
|
7
|
+
using System.Linq;
|
|
8
|
+
using UnityEditor;
|
|
9
|
+
using UnityEngine;
|
|
10
|
+
|
|
11
|
+
namespace UCP.Bridge
|
|
12
|
+
{
|
|
13
|
+
/// <summary>
|
|
14
|
+
/// Runs UI Toolkit work over editor update frames so layout and repaint are never
|
|
15
|
+
/// blocked by the initiating JSON-RPC handler. Operations are serialized because
|
|
16
|
+
/// the capture backend requires a focused editor window.
|
|
17
|
+
/// </summary>
|
|
18
|
+
internal static class UiOperationManager
|
|
19
|
+
{
|
|
20
|
+
private const int MaxRetainedOperations = 16;
|
|
21
|
+
private const double CompletedTtlSeconds = 300.0;
|
|
22
|
+
// Hard ceiling from enqueue through every requested state, independent of
|
|
23
|
+
// each state's settle timeout.
|
|
24
|
+
internal const double MaxOperationDurationSeconds = 300.0;
|
|
25
|
+
private static readonly Dictionary<string, UiOperationRecord> Operations =
|
|
26
|
+
new Dictionary<string, UiOperationRecord>(StringComparer.Ordinal);
|
|
27
|
+
private static readonly Queue<string> Queue = new Queue<string>();
|
|
28
|
+
private static UiRenderOperation _active;
|
|
29
|
+
|
|
30
|
+
static UiOperationManager()
|
|
31
|
+
{
|
|
32
|
+
EditorApplication.update += Tick;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
internal static Dictionary<string, object> Start(
|
|
36
|
+
string operation,
|
|
37
|
+
Dictionary<string, object> parameters)
|
|
38
|
+
{
|
|
39
|
+
if (Operations.Count >= MaxRetainedOperations)
|
|
40
|
+
{
|
|
41
|
+
PruneCompleted(true);
|
|
42
|
+
if (Operations.Count >= MaxRetainedOperations)
|
|
43
|
+
throw new ArgumentException("Too many retained UI operations; wait for an active operation to finish");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var operationId = "ui-" + Guid.NewGuid().ToString("N").Substring(0, 12);
|
|
47
|
+
var record = new UiOperationRecord(operationId, operation, parameters);
|
|
48
|
+
Operations.Add(operationId, record);
|
|
49
|
+
Queue.Enqueue(operationId);
|
|
50
|
+
return new Dictionary<string, object>
|
|
51
|
+
{
|
|
52
|
+
["status"] = "started",
|
|
53
|
+
["operationId"] = operationId,
|
|
54
|
+
["operation"] = operation
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
internal static Dictionary<string, object> Status(string operationId)
|
|
59
|
+
{
|
|
60
|
+
if (string.IsNullOrWhiteSpace(operationId))
|
|
61
|
+
throw new ArgumentException("Missing 'operationId' parameter");
|
|
62
|
+
|
|
63
|
+
if (!Operations.TryGetValue(operationId, out var record))
|
|
64
|
+
{
|
|
65
|
+
return new Dictionary<string, object>
|
|
66
|
+
{
|
|
67
|
+
["found"] = false,
|
|
68
|
+
["operationId"] = operationId
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
var envelope = record.Envelope();
|
|
73
|
+
envelope["found"] = true;
|
|
74
|
+
return envelope;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
internal static void ResetForTests()
|
|
78
|
+
{
|
|
79
|
+
Shutdown();
|
|
80
|
+
Operations.Clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
internal static void TickForTests()
|
|
84
|
+
{
|
|
85
|
+
Tick();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// <summary>Promotes the next queued record to active without running its first tick.</summary>
|
|
89
|
+
internal static void ActivateNextForTests()
|
|
90
|
+
{
|
|
91
|
+
ActivateNext();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static void Tick()
|
|
95
|
+
{
|
|
96
|
+
// Idle editors pay nothing: skip the pruning scan until an operation exists.
|
|
97
|
+
if (_active == null && Queue.Count == 0 && Operations.Count == 0)
|
|
98
|
+
return;
|
|
99
|
+
|
|
100
|
+
PruneCompleted(false);
|
|
101
|
+
ExpireQueuedOperations();
|
|
102
|
+
if (_active == null)
|
|
103
|
+
ActivateNext();
|
|
104
|
+
if (_active == null)
|
|
105
|
+
return;
|
|
106
|
+
|
|
107
|
+
try
|
|
108
|
+
{
|
|
109
|
+
_active.Tick();
|
|
110
|
+
}
|
|
111
|
+
catch (Exception exception)
|
|
112
|
+
{
|
|
113
|
+
_active.Fail(exception);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!_active.IsTerminal)
|
|
117
|
+
return;
|
|
118
|
+
|
|
119
|
+
var completed = _active;
|
|
120
|
+
_active = null;
|
|
121
|
+
var completionError = completed.Error;
|
|
122
|
+
try
|
|
123
|
+
{
|
|
124
|
+
completed.Dispose();
|
|
125
|
+
}
|
|
126
|
+
catch (Exception exception)
|
|
127
|
+
{
|
|
128
|
+
var cleanupError = UiRenderOperation.BuildError(exception);
|
|
129
|
+
if (completionError == null)
|
|
130
|
+
{
|
|
131
|
+
completionError = cleanupError;
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
{
|
|
135
|
+
completionError = new Dictionary<string, object>(completionError, StringComparer.Ordinal)
|
|
136
|
+
{
|
|
137
|
+
["cleanupError"] = cleanupError
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
var record = Operations[completed.OperationId];
|
|
142
|
+
CompleteAndBroadcast(record, completed.Result, completionError);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private static void ActivateNext()
|
|
146
|
+
{
|
|
147
|
+
while (Queue.Count > 0)
|
|
148
|
+
{
|
|
149
|
+
var id = Queue.Dequeue();
|
|
150
|
+
if (!Operations.TryGetValue(id, out var record) || record.IsTerminal)
|
|
151
|
+
continue;
|
|
152
|
+
|
|
153
|
+
record.MarkRunning();
|
|
154
|
+
try
|
|
155
|
+
{
|
|
156
|
+
_active = new UiRenderOperation(record);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
catch (Exception exception)
|
|
160
|
+
{
|
|
161
|
+
_active = null;
|
|
162
|
+
CompleteAndBroadcast(record, null, UiRenderOperation.BuildError(exception));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private static void ExpireQueuedOperations()
|
|
168
|
+
{
|
|
169
|
+
var count = Queue.Count;
|
|
170
|
+
var now = EditorApplication.timeSinceStartup;
|
|
171
|
+
for (var index = 0; index < count; index++)
|
|
172
|
+
{
|
|
173
|
+
var id = Queue.Dequeue();
|
|
174
|
+
if (!Operations.TryGetValue(id, out var record) || record.IsTerminal)
|
|
175
|
+
continue;
|
|
176
|
+
|
|
177
|
+
var elapsed = now - record.StartedAt;
|
|
178
|
+
if (HasExceededOperationDuration(record.StartedAt, now))
|
|
179
|
+
{
|
|
180
|
+
CompleteAndBroadcast(
|
|
181
|
+
record,
|
|
182
|
+
null,
|
|
183
|
+
UiRenderOperation.BuildError(CreateOperationTimeout(
|
|
184
|
+
record.Operation,
|
|
185
|
+
0,
|
|
186
|
+
elapsed)));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
Queue.Enqueue(id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
internal static bool HasExceededOperationDuration(double startedAt, double now)
|
|
195
|
+
{
|
|
196
|
+
return now - startedAt > MaxOperationDurationSeconds;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
internal static UiOperationException CreateOperationTimeout(
|
|
200
|
+
string operation,
|
|
201
|
+
int completedStateCount,
|
|
202
|
+
double elapsed)
|
|
203
|
+
{
|
|
204
|
+
return new UiOperationException(
|
|
205
|
+
"timeout",
|
|
206
|
+
$"UI operation '{operation}' exceeded the {MaxOperationDurationSeconds:F0}s overall duration limit",
|
|
207
|
+
new Dictionary<string, object>
|
|
208
|
+
{
|
|
209
|
+
["scope"] = "operation",
|
|
210
|
+
["timeoutSeconds"] = MaxOperationDurationSeconds,
|
|
211
|
+
["durationSeconds"] = UiValue.FiniteOrNull(elapsed),
|
|
212
|
+
["completedStateCount"] = completedStateCount
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private static void CompleteAndBroadcast(
|
|
217
|
+
UiOperationRecord record,
|
|
218
|
+
object result,
|
|
219
|
+
Dictionary<string, object> error)
|
|
220
|
+
{
|
|
221
|
+
record.Complete(result, error);
|
|
222
|
+
BridgeServer.BroadcastNotification("ui/result", record.Envelope());
|
|
223
|
+
|
|
224
|
+
if (record.Error != null &&
|
|
225
|
+
record.Error.TryGetValue("code", out var code) &&
|
|
226
|
+
(string.Equals(code?.ToString(), "internal_error", StringComparison.Ordinal) ||
|
|
227
|
+
string.Equals(code?.ToString(), "cleanup_failed", StringComparison.Ordinal)))
|
|
228
|
+
{
|
|
229
|
+
Debug.LogError($"[UCP] ui/{record.Operation} operation {record.OperationId} failed: " +
|
|
230
|
+
record.Error["message"]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private static void PruneCompleted(bool force)
|
|
235
|
+
{
|
|
236
|
+
var now = EditorApplication.timeSinceStartup;
|
|
237
|
+
var expired = Operations
|
|
238
|
+
.Where(pair => pair.Value.IsTerminal &&
|
|
239
|
+
(force || now - pair.Value.CompletedAt >= CompletedTtlSeconds))
|
|
240
|
+
.Select(pair => pair.Key)
|
|
241
|
+
.ToList();
|
|
242
|
+
foreach (var id in expired)
|
|
243
|
+
Operations.Remove(id);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
internal static void Shutdown()
|
|
247
|
+
{
|
|
248
|
+
// The bridge calls this before closing client sockets on reload/quit.
|
|
249
|
+
// Retain terminal records for status recovery until the domain unloads.
|
|
250
|
+
foreach (var record in Operations.Values.Where(record => !record.IsTerminal).ToList())
|
|
251
|
+
{
|
|
252
|
+
CompleteAndBroadcast(record, null, new Dictionary<string, object>
|
|
253
|
+
{
|
|
254
|
+
["code"] = "editor_shutdown",
|
|
255
|
+
["message"] = "UI operation interrupted by an Editor domain reload or shutdown"
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
var active = _active;
|
|
259
|
+
_active = null;
|
|
260
|
+
try
|
|
261
|
+
{
|
|
262
|
+
active?.Dispose();
|
|
263
|
+
}
|
|
264
|
+
catch
|
|
265
|
+
{
|
|
266
|
+
// Reload and quit must continue after best-effort cleanup.
|
|
267
|
+
}
|
|
268
|
+
finally
|
|
269
|
+
{
|
|
270
|
+
Queue.Clear();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
internal sealed class UiOperationRecord
|
|
276
|
+
{
|
|
277
|
+
internal UiOperationRecord(
|
|
278
|
+
string operationId,
|
|
279
|
+
string operation,
|
|
280
|
+
Dictionary<string, object> parameters)
|
|
281
|
+
{
|
|
282
|
+
OperationId = operationId;
|
|
283
|
+
Operation = operation;
|
|
284
|
+
Parameters = parameters != null
|
|
285
|
+
? new Dictionary<string, object>(parameters, StringComparer.Ordinal)
|
|
286
|
+
: new Dictionary<string, object>(StringComparer.Ordinal);
|
|
287
|
+
Status = "queued";
|
|
288
|
+
StartedAtUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
|
289
|
+
StartedAt = EditorApplication.timeSinceStartup;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
internal string OperationId { get; }
|
|
293
|
+
internal string Operation { get; }
|
|
294
|
+
internal Dictionary<string, object> Parameters { get; }
|
|
295
|
+
internal string Status { get; private set; }
|
|
296
|
+
internal object Result { get; private set; }
|
|
297
|
+
internal Dictionary<string, object> Error { get; private set; }
|
|
298
|
+
internal string StartedAtUtc { get; }
|
|
299
|
+
internal double StartedAt { get; }
|
|
300
|
+
internal double CompletedAt { get; private set; }
|
|
301
|
+
internal bool IsTerminal => Status == "completed" || Status == "failed";
|
|
302
|
+
|
|
303
|
+
internal void MarkRunning()
|
|
304
|
+
{
|
|
305
|
+
Status = "running";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
internal void Complete(object result, Dictionary<string, object> error)
|
|
309
|
+
{
|
|
310
|
+
Result = UiJsonSanitizer.Sanitize(result);
|
|
311
|
+
Error = error == null
|
|
312
|
+
? null
|
|
313
|
+
: (Dictionary<string, object>)UiJsonSanitizer.Sanitize(error);
|
|
314
|
+
Status = Error == null ? "completed" : "failed";
|
|
315
|
+
CompletedAt = EditorApplication.timeSinceStartup;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
internal Dictionary<string, object> Envelope()
|
|
319
|
+
{
|
|
320
|
+
var envelope = new Dictionary<string, object>
|
|
321
|
+
{
|
|
322
|
+
["operationId"] = OperationId,
|
|
323
|
+
["operation"] = Operation,
|
|
324
|
+
["status"] = Status,
|
|
325
|
+
["startedAtUtc"] = StartedAtUtc
|
|
326
|
+
};
|
|
327
|
+
if (Result != null)
|
|
328
|
+
envelope["result"] = Result;
|
|
329
|
+
if (Error != null)
|
|
330
|
+
envelope["error"] = Error;
|
|
331
|
+
return envelope;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
internal sealed class UiRenderOperation : IDisposable
|
|
336
|
+
{
|
|
337
|
+
private enum Phase
|
|
338
|
+
{
|
|
339
|
+
Initialize,
|
|
340
|
+
OpenScenario,
|
|
341
|
+
Settle,
|
|
342
|
+
Terminal
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private readonly UiOperationRecord _record;
|
|
346
|
+
private readonly bool _capture;
|
|
347
|
+
private readonly bool _inspect;
|
|
348
|
+
private readonly bool _check;
|
|
349
|
+
private readonly bool _failOnWarnings;
|
|
350
|
+
private UiInspectOptions _inspectOptions;
|
|
351
|
+
private readonly List<object> _stateResults = new List<object>();
|
|
352
|
+
private Phase _phase;
|
|
353
|
+
private List<UiResolvedScenario> _scenarios;
|
|
354
|
+
private int _scenarioIndex;
|
|
355
|
+
private UiResolvedScenario _scenario;
|
|
356
|
+
private UiApplyReport _applyReport;
|
|
357
|
+
private UiHostWindow _host;
|
|
358
|
+
private UiCaptureSurface _captureSurface;
|
|
359
|
+
private EditorWindow _previousFocusedWindow;
|
|
360
|
+
private UiGeometrySample _geometry;
|
|
361
|
+
private string _lastGeometryHash;
|
|
362
|
+
private int _geometryStableFrames;
|
|
363
|
+
private string _lastPixelHash;
|
|
364
|
+
private int _pixelStableFrames;
|
|
365
|
+
private UiCaptureSample _latestCapture;
|
|
366
|
+
private double _scenarioStartedAt;
|
|
367
|
+
private int _warmupFrames;
|
|
368
|
+
private Dictionary<string, object> _lintResult;
|
|
369
|
+
|
|
370
|
+
internal UiRenderOperation(UiOperationRecord record)
|
|
371
|
+
{
|
|
372
|
+
_record = record;
|
|
373
|
+
_capture = record.Operation == "screenshot" || record.Operation == "check";
|
|
374
|
+
_inspect = record.Operation == "inspect" || record.Operation == "check";
|
|
375
|
+
_check = record.Operation == "check";
|
|
376
|
+
_failOnWarnings = ReadBool(record.Parameters, "failOnWarnings", false);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
internal string OperationId => _record.OperationId;
|
|
380
|
+
internal object Result { get; private set; }
|
|
381
|
+
internal Dictionary<string, object> Error { get; private set; }
|
|
382
|
+
internal bool IsTerminal => _phase == Phase.Terminal;
|
|
383
|
+
|
|
384
|
+
internal void Tick()
|
|
385
|
+
{
|
|
386
|
+
var now = EditorApplication.timeSinceStartup;
|
|
387
|
+
if (UiOperationManager.HasExceededOperationDuration(_record.StartedAt, now))
|
|
388
|
+
{
|
|
389
|
+
throw UiOperationManager.CreateOperationTimeout(
|
|
390
|
+
_record.Operation,
|
|
391
|
+
_stateResults.Count,
|
|
392
|
+
now - _record.StartedAt);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
switch (_phase)
|
|
396
|
+
{
|
|
397
|
+
case Phase.Initialize:
|
|
398
|
+
Initialize();
|
|
399
|
+
break;
|
|
400
|
+
case Phase.OpenScenario:
|
|
401
|
+
OpenScenario();
|
|
402
|
+
break;
|
|
403
|
+
case Phase.Settle:
|
|
404
|
+
Settle();
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
internal void Fail(Exception exception)
|
|
410
|
+
{
|
|
411
|
+
if (IsTerminal)
|
|
412
|
+
return;
|
|
413
|
+
|
|
414
|
+
Error = BuildError(exception);
|
|
415
|
+
_phase = Phase.Terminal;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private void Initialize()
|
|
419
|
+
{
|
|
420
|
+
UiHostCapabilities.EnsureAvailable(_record.Operation);
|
|
421
|
+
_inspectOptions = _inspect ? UiInspectOptions.Parse(_record.Parameters) : null;
|
|
422
|
+
|
|
423
|
+
if (_check)
|
|
424
|
+
{
|
|
425
|
+
var lintParameters = new Dictionary<string, object>
|
|
426
|
+
{
|
|
427
|
+
["paths"] = new List<object> { ReadRequiredString(_record.Parameters, "target") },
|
|
428
|
+
["failOnWarnings"] = _failOnWarnings
|
|
429
|
+
};
|
|
430
|
+
if (_record.Parameters.TryGetValue("maxDiagnostics", out var maxDiagnostics))
|
|
431
|
+
lintParameters["maxDiagnostics"] = maxDiagnostics;
|
|
432
|
+
_lintResult = UiLintService.Run(lintParameters);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
var allStates = ReadBool(_record.Parameters, "allStates", false);
|
|
436
|
+
_scenarios = UiScenarioLoader.Resolve(_record.Parameters, allStates);
|
|
437
|
+
if (_scenarios == null || _scenarios.Count == 0)
|
|
438
|
+
throw new ArgumentException("No UI scenarios matched the request");
|
|
439
|
+
|
|
440
|
+
_previousFocusedWindow = EditorWindow.focusedWindow;
|
|
441
|
+
_scenarioIndex = 0;
|
|
442
|
+
_phase = Phase.OpenScenario;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private void OpenScenario()
|
|
446
|
+
{
|
|
447
|
+
DisposeScenario();
|
|
448
|
+
_scenario = _scenarios[_scenarioIndex];
|
|
449
|
+
_host = UiHostWindow.Open(_scenario.Viewport.Width, _scenario.Viewport.Height);
|
|
450
|
+
_scenario.Document.CloneTree(_host.ContentRoot);
|
|
451
|
+
_applyReport = UiScenarioApplier.Apply(_host.ContentRoot, _scenario);
|
|
452
|
+
if (_capture)
|
|
453
|
+
{
|
|
454
|
+
_captureSurface = new UiCaptureSurface(
|
|
455
|
+
_scenario.Viewport.Width,
|
|
456
|
+
_scenario.Viewport.Height);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
_scenarioStartedAt = EditorApplication.timeSinceStartup;
|
|
460
|
+
_lastGeometryHash = null;
|
|
461
|
+
_geometryStableFrames = 0;
|
|
462
|
+
_lastPixelHash = null;
|
|
463
|
+
_pixelStableFrames = 0;
|
|
464
|
+
_latestCapture = null;
|
|
465
|
+
_warmupFrames = 0;
|
|
466
|
+
_phase = Phase.Settle;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private void Settle()
|
|
470
|
+
{
|
|
471
|
+
if (_host == null)
|
|
472
|
+
throw new InvalidOperationException("The transient UI host window was closed before the operation completed");
|
|
473
|
+
|
|
474
|
+
var elapsed = EditorApplication.timeSinceStartup - _scenarioStartedAt;
|
|
475
|
+
if (elapsed > _scenario.Settle.TimeoutSeconds)
|
|
476
|
+
{
|
|
477
|
+
throw new UiOperationException(
|
|
478
|
+
"timeout",
|
|
479
|
+
$"UI state '{_scenario.StateName}' did not settle within " +
|
|
480
|
+
$"{_scenario.Settle.TimeoutSeconds:F1}s",
|
|
481
|
+
new Dictionary<string, object>
|
|
482
|
+
{
|
|
483
|
+
["scenario"] = _scenario.ToDictionary(),
|
|
484
|
+
["geometry"] = _geometry.ToDictionary(_geometryStableFrames),
|
|
485
|
+
["pixelStableFrames"] = _pixelStableFrames,
|
|
486
|
+
["completedStateCount"] = _stateResults.Count
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
_host.Pump();
|
|
491
|
+
_warmupFrames++;
|
|
492
|
+
_geometry = UiGeometrySampler.Measure(_host.ContentRoot);
|
|
493
|
+
var geometryValid = _warmupFrames >= 2 && _host.hasFocus && _geometry.IsValid;
|
|
494
|
+
if (geometryValid && string.Equals(_lastGeometryHash, _geometry.Hash, StringComparison.Ordinal))
|
|
495
|
+
_geometryStableFrames++;
|
|
496
|
+
else
|
|
497
|
+
_geometryStableFrames = geometryValid ? 1 : 0;
|
|
498
|
+
_lastGeometryHash = _geometry.Hash;
|
|
499
|
+
|
|
500
|
+
if (_geometryStableFrames < _scenario.Settle.StableFrames)
|
|
501
|
+
{
|
|
502
|
+
ResetPixelStability();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (_capture)
|
|
507
|
+
{
|
|
508
|
+
_latestCapture = _captureSurface.Capture(_host);
|
|
509
|
+
if (string.Equals(_lastPixelHash, _latestCapture.PixelHash, StringComparison.Ordinal))
|
|
510
|
+
_pixelStableFrames++;
|
|
511
|
+
else
|
|
512
|
+
_pixelStableFrames = 1;
|
|
513
|
+
_lastPixelHash = _latestCapture.PixelHash;
|
|
514
|
+
|
|
515
|
+
if (_pixelStableFrames < _scenario.Settle.PixelStableFrames)
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
CompleteScenario(elapsed);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private void CompleteScenario(double elapsed)
|
|
523
|
+
{
|
|
524
|
+
var metadata = UiScenarioApplier.GetCollectionMetadata(_host.ContentRoot);
|
|
525
|
+
var stateResult = new Dictionary<string, object>
|
|
526
|
+
{
|
|
527
|
+
["scenario"] = _scenario.ToDictionary(),
|
|
528
|
+
["apply"] = _applyReport.ToDictionary(),
|
|
529
|
+
["settle"] = BuildSettleResult(elapsed)
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
Dictionary<string, object> audit = null;
|
|
533
|
+
if (_inspect)
|
|
534
|
+
{
|
|
535
|
+
stateResult["snapshot"] = UiVisualTreeInspector.Inspect(
|
|
536
|
+
_host.ContentRoot,
|
|
537
|
+
_inspectOptions,
|
|
538
|
+
metadata);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (_check)
|
|
542
|
+
{
|
|
543
|
+
audit = UiAudit.Run(_host.ContentRoot, _applyReport, metadata);
|
|
544
|
+
stateResult["audit"] = audit;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (_capture)
|
|
548
|
+
{
|
|
549
|
+
var artifactPath = WriteCaptureArtifact(_latestCapture.Png);
|
|
550
|
+
var capture = new Dictionary<string, object>
|
|
551
|
+
{
|
|
552
|
+
["artifactPath"] = artifactPath,
|
|
553
|
+
["width"] = _latestCapture.Width,
|
|
554
|
+
["height"] = _latestCapture.Height,
|
|
555
|
+
["pixelHash"] = _latestCapture.PixelHash,
|
|
556
|
+
["pixelStableFrames"] = _pixelStableFrames,
|
|
557
|
+
["sampledDistinctColors"] = _latestCapture.SampledDistinctColors,
|
|
558
|
+
["nonTransparentPixels"] = _latestCapture.NonTransparentPixels,
|
|
559
|
+
["pixelsPerPoint"] = UiValue.FiniteOrNull(EditorGUIUtility.pixelsPerPoint),
|
|
560
|
+
["compositorScale"] = UiValue.FiniteOrNull(
|
|
561
|
+
1f / Mathf.Max(1f, EditorGUIUtility.pixelsPerPoint)),
|
|
562
|
+
["graphicsDeviceType"] = SystemInfo.graphicsDeviceType.ToString()
|
|
563
|
+
};
|
|
564
|
+
stateResult["capture"] = capture;
|
|
565
|
+
stateResult["artifactPath"] = artifactPath;
|
|
566
|
+
stateResult["width"] = _latestCapture.Width;
|
|
567
|
+
stateResult["height"] = _latestCapture.Height;
|
|
568
|
+
stateResult["pixelHash"] = _latestCapture.PixelHash;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (_check)
|
|
572
|
+
{
|
|
573
|
+
var auditPassed = ReadBool(audit, "passed", false);
|
|
574
|
+
var warnings = audit != null && audit.TryGetValue("warningCount", out var warningValue)
|
|
575
|
+
? Convert.ToInt32(warningValue)
|
|
576
|
+
: 0;
|
|
577
|
+
stateResult["passed"] = auditPassed && (!_failOnWarnings || warnings == 0);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
_stateResults.Add(stateResult);
|
|
581
|
+
_scenarioIndex++;
|
|
582
|
+
Result = BuildResult();
|
|
583
|
+
DisposeScenario();
|
|
584
|
+
if (_scenarioIndex < _scenarios.Count)
|
|
585
|
+
{
|
|
586
|
+
_phase = Phase.OpenScenario;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
_phase = Phase.Terminal;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private Dictionary<string, object> BuildSettleResult(double elapsed)
|
|
594
|
+
{
|
|
595
|
+
var settle = _geometry.ToDictionary(_geometryStableFrames);
|
|
596
|
+
settle["pixelStableFrames"] = _pixelStableFrames;
|
|
597
|
+
settle["durationSeconds"] = UiValue.FiniteOrNull(elapsed);
|
|
598
|
+
settle["focused"] = _host != null && _host.hasFocus;
|
|
599
|
+
return settle;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private Dictionary<string, object> BuildResult()
|
|
603
|
+
{
|
|
604
|
+
var result = new Dictionary<string, object>
|
|
605
|
+
{
|
|
606
|
+
["operation"] = _record.Operation,
|
|
607
|
+
["target"] = ReadRequiredString(_record.Parameters, "target"),
|
|
608
|
+
["stateCount"] = _stateResults.Count,
|
|
609
|
+
["states"] = _stateResults,
|
|
610
|
+
["capabilities"] = UiHostCapabilities.Describe()
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
if (_lintResult != null)
|
|
614
|
+
result["lint"] = _lintResult;
|
|
615
|
+
|
|
616
|
+
if (_stateResults.Count == 1 && _stateResults[0] is Dictionary<string, object> onlyState)
|
|
617
|
+
{
|
|
618
|
+
foreach (var pair in onlyState)
|
|
619
|
+
result[pair.Key] = pair.Value;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (_check)
|
|
623
|
+
{
|
|
624
|
+
var lintPassed = _lintResult != null && ReadBool(_lintResult, "passed", false);
|
|
625
|
+
var statesPassed = _stateResults.All(state =>
|
|
626
|
+
state is Dictionary<string, object> dictionary && ReadBool(dictionary, "passed", false));
|
|
627
|
+
result["passed"] = lintPassed && statesPassed;
|
|
628
|
+
var errorCount = ReadCount(_lintResult, "errorCount");
|
|
629
|
+
var warningCount = ReadCount(_lintResult, "warningCount");
|
|
630
|
+
foreach (var state in _stateResults.OfType<Dictionary<string, object>>())
|
|
631
|
+
{
|
|
632
|
+
if (state.TryGetValue("audit", out var auditObject) &&
|
|
633
|
+
auditObject is Dictionary<string, object> audit)
|
|
634
|
+
{
|
|
635
|
+
errorCount += ReadCount(audit, "errorCount");
|
|
636
|
+
warningCount += ReadCount(audit, "warningCount");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
result["errorCount"] = errorCount;
|
|
640
|
+
result["warningCount"] = warningCount;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
var elementCount = 0;
|
|
644
|
+
foreach (var state in _stateResults.OfType<Dictionary<string, object>>())
|
|
645
|
+
{
|
|
646
|
+
if (state.TryGetValue("snapshot", out var snapshotObject) &&
|
|
647
|
+
snapshotObject is Dictionary<string, object> snapshot)
|
|
648
|
+
elementCount += ReadCount(snapshot, "returnedElementCount");
|
|
649
|
+
}
|
|
650
|
+
if (_inspect)
|
|
651
|
+
result["elementCount"] = elementCount;
|
|
652
|
+
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private string WriteCaptureArtifact(byte[] png)
|
|
657
|
+
{
|
|
658
|
+
if (png == null || png.Length == 0)
|
|
659
|
+
throw new InvalidOperationException("No PNG data was available after capture settled");
|
|
660
|
+
|
|
661
|
+
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
|
662
|
+
var outputDirectory = Path.Combine(projectRoot, "Library", "UCP", "UiCaptures");
|
|
663
|
+
Directory.CreateDirectory(outputDirectory);
|
|
664
|
+
var path = Path.Combine(outputDirectory, CaptureArtifactFileName(
|
|
665
|
+
_scenario.TargetPath,
|
|
666
|
+
_scenario.StateName,
|
|
667
|
+
_scenarioIndex,
|
|
668
|
+
OperationId));
|
|
669
|
+
File.WriteAllBytes(path, png);
|
|
670
|
+
return UiValue.NormalizePath(Path.GetFullPath(path));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
internal static string CaptureArtifactFileName(
|
|
674
|
+
string targetPath,
|
|
675
|
+
string stateName,
|
|
676
|
+
int scenarioIndex,
|
|
677
|
+
string operationId)
|
|
678
|
+
{
|
|
679
|
+
// "Inventory.ucp-ui.json" should yield "Inventory-...", not "Inventory.ucp-ui-...".
|
|
680
|
+
var targetName = Path.GetFileNameWithoutExtension(targetPath) ?? string.Empty;
|
|
681
|
+
const string scenarioSuffix = ".ucp-ui";
|
|
682
|
+
if (targetName.EndsWith(scenarioSuffix, StringComparison.OrdinalIgnoreCase))
|
|
683
|
+
targetName = targetName.Substring(0, targetName.Length - scenarioSuffix.Length);
|
|
684
|
+
var targetToken = UiValue.SafeFileName(targetName);
|
|
685
|
+
var stateToken = UiValue.SafeFileName(stateName);
|
|
686
|
+
return $"{targetToken}-{stateToken}-s{scenarioIndex:D4}-{operationId}.png";
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private void ResetPixelStability()
|
|
690
|
+
{
|
|
691
|
+
_lastPixelHash = null;
|
|
692
|
+
_pixelStableFrames = 0;
|
|
693
|
+
_latestCapture = null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private void DisposeScenario()
|
|
697
|
+
{
|
|
698
|
+
var captureSurface = _captureSurface;
|
|
699
|
+
_captureSurface = null;
|
|
700
|
+
var host = _host;
|
|
701
|
+
_host = null;
|
|
702
|
+
|
|
703
|
+
var error = UiCleanup.RunAll(
|
|
704
|
+
() => captureSurface?.Dispose(),
|
|
705
|
+
() => UiHostWindow.CloseAndDestroy(host));
|
|
706
|
+
if (error != null)
|
|
707
|
+
{
|
|
708
|
+
throw new UiOperationException(
|
|
709
|
+
"cleanup_failed",
|
|
710
|
+
"The UI harness could not release all transient resources",
|
|
711
|
+
new Dictionary<string, object>
|
|
712
|
+
{
|
|
713
|
+
["exceptionType"] = error.GetType().FullName,
|
|
714
|
+
["message"] = error.Message
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
public void Dispose()
|
|
720
|
+
{
|
|
721
|
+
var previousFocusedWindow = _previousFocusedWindow;
|
|
722
|
+
_previousFocusedWindow = null;
|
|
723
|
+
var error = UiCleanup.RunAll(
|
|
724
|
+
DisposeScenario,
|
|
725
|
+
() =>
|
|
726
|
+
{
|
|
727
|
+
if (previousFocusedWindow == null)
|
|
728
|
+
return;
|
|
729
|
+
try
|
|
730
|
+
{
|
|
731
|
+
previousFocusedWindow.Focus();
|
|
732
|
+
}
|
|
733
|
+
catch
|
|
734
|
+
{
|
|
735
|
+
// The previously focused window may have been closed during the operation.
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
if (error != null)
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
internal static Dictionary<string, object> BuildError(Exception exception)
|
|
743
|
+
{
|
|
744
|
+
string code;
|
|
745
|
+
object details = null;
|
|
746
|
+
if (exception is UiOperationException operationException)
|
|
747
|
+
{
|
|
748
|
+
code = operationException.Code;
|
|
749
|
+
details = operationException.Details;
|
|
750
|
+
}
|
|
751
|
+
else if (exception is UiScenarioException scenarioException)
|
|
752
|
+
{
|
|
753
|
+
code = scenarioException.Code;
|
|
754
|
+
details = new Dictionary<string, object>
|
|
755
|
+
{
|
|
756
|
+
["location"] = scenarioException.Location
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
else if (exception is ArgumentException)
|
|
760
|
+
{
|
|
761
|
+
code = "invalid_params";
|
|
762
|
+
}
|
|
763
|
+
else
|
|
764
|
+
{
|
|
765
|
+
code = "internal_error";
|
|
766
|
+
details = new Dictionary<string, object>
|
|
767
|
+
{
|
|
768
|
+
["exceptionType"] = exception.GetType().FullName
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
var error = new Dictionary<string, object>
|
|
773
|
+
{
|
|
774
|
+
["code"] = code,
|
|
775
|
+
["message"] = exception.Message
|
|
776
|
+
};
|
|
777
|
+
if (details != null)
|
|
778
|
+
error["details"] = details;
|
|
779
|
+
return (Dictionary<string, object>)UiJsonSanitizer.Sanitize(error);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private static bool ReadBool(
|
|
783
|
+
Dictionary<string, object> parameters,
|
|
784
|
+
string key,
|
|
785
|
+
bool defaultValue)
|
|
786
|
+
{
|
|
787
|
+
if (parameters == null || !parameters.TryGetValue(key, out var value) || value == null)
|
|
788
|
+
return defaultValue;
|
|
789
|
+
if (value is bool boolean)
|
|
790
|
+
return boolean;
|
|
791
|
+
if (bool.TryParse(value.ToString(), out var parsed))
|
|
792
|
+
return parsed;
|
|
793
|
+
throw new ArgumentException($"'{key}' must be a boolean");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private static string ReadRequiredString(Dictionary<string, object> parameters, string key)
|
|
797
|
+
{
|
|
798
|
+
if (parameters == null || !parameters.TryGetValue(key, out var value) ||
|
|
799
|
+
string.IsNullOrWhiteSpace(value?.ToString()))
|
|
800
|
+
throw new ArgumentException($"Missing '{key}' parameter");
|
|
801
|
+
return value.ToString();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private static int ReadCount(Dictionary<string, object> values, string key)
|
|
805
|
+
{
|
|
806
|
+
if (values == null || !values.TryGetValue(key, out var value) || value == null)
|
|
807
|
+
return 0;
|
|
808
|
+
try
|
|
809
|
+
{
|
|
810
|
+
return Convert.ToInt32(value);
|
|
811
|
+
}
|
|
812
|
+
catch
|
|
813
|
+
{
|
|
814
|
+
return 0;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
internal static class UiJsonSanitizer
|
|
820
|
+
{
|
|
821
|
+
internal static object Sanitize(object value)
|
|
822
|
+
{
|
|
823
|
+
if (value == null || value is string || value is bool || value is int || value is long)
|
|
824
|
+
return value;
|
|
825
|
+
if (value is char character)
|
|
826
|
+
return character.ToString();
|
|
827
|
+
if (value is byte || value is sbyte || value is short || value is ushort)
|
|
828
|
+
return Convert.ToInt32(value, CultureInfo.InvariantCulture);
|
|
829
|
+
if (value is uint unsignedInteger)
|
|
830
|
+
return (long)unsignedInteger;
|
|
831
|
+
if (value is ulong unsignedLong)
|
|
832
|
+
return unsignedLong <= long.MaxValue
|
|
833
|
+
? (object)(long)unsignedLong
|
|
834
|
+
: unsignedLong.ToString(CultureInfo.InvariantCulture);
|
|
835
|
+
if (value is decimal decimalValue)
|
|
836
|
+
return decimalValue.ToString(CultureInfo.InvariantCulture);
|
|
837
|
+
if (value is IntPtr pointer)
|
|
838
|
+
return pointer.ToInt64();
|
|
839
|
+
if (value is UIntPtr unsignedPointer)
|
|
840
|
+
{
|
|
841
|
+
var numericValue = unsignedPointer.ToUInt64();
|
|
842
|
+
return numericValue <= long.MaxValue
|
|
843
|
+
? (object)(long)numericValue
|
|
844
|
+
: numericValue.ToString(CultureInfo.InvariantCulture);
|
|
845
|
+
}
|
|
846
|
+
if (value is float floatValue)
|
|
847
|
+
return UiValue.FiniteOrNull(floatValue);
|
|
848
|
+
if (value is double doubleValue)
|
|
849
|
+
return UiValue.FiniteOrNull(doubleValue);
|
|
850
|
+
if (value is Enum enumValue)
|
|
851
|
+
return enumValue.ToString();
|
|
852
|
+
if (value is IDictionary dictionary)
|
|
853
|
+
{
|
|
854
|
+
var result = new Dictionary<string, object>(StringComparer.Ordinal);
|
|
855
|
+
foreach (DictionaryEntry pair in dictionary)
|
|
856
|
+
result[pair.Key?.ToString() ?? "null"] = Sanitize(pair.Value);
|
|
857
|
+
return result;
|
|
858
|
+
}
|
|
859
|
+
if (value is IEnumerable enumerable)
|
|
860
|
+
{
|
|
861
|
+
var result = new List<object>();
|
|
862
|
+
foreach (var item in enumerable)
|
|
863
|
+
result.Add(Sanitize(item));
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
return value.ToString();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
#endif
|