@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,854 @@
|
|
|
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
|
+
using UnityEngine.UIElements;
|
|
11
|
+
|
|
12
|
+
namespace UCP.Bridge
|
|
13
|
+
{
|
|
14
|
+
internal static class UiScenarioLoader
|
|
15
|
+
{
|
|
16
|
+
private const int MaxFixtureStates = 64;
|
|
17
|
+
private const long MaxViewportPixelArea = 8_388_608L;
|
|
18
|
+
|
|
19
|
+
private static readonly HashSet<string> FixtureFields = new HashSet<string>(StringComparer.Ordinal)
|
|
20
|
+
{
|
|
21
|
+
"schemaVersion", "document", "defaultState", "viewport", "settle", "data", "set", "collections", "states"
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
private static readonly HashSet<string> StateFields = new HashSet<string>(StringComparer.Ordinal)
|
|
25
|
+
{
|
|
26
|
+
"viewport", "settle", "data", "set", "collections"
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
private static readonly HashSet<string> ViewportFields = new HashSet<string>(StringComparer.Ordinal)
|
|
30
|
+
{
|
|
31
|
+
"width", "height"
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
private static readonly HashSet<string> SettleFields = new HashSet<string>(StringComparer.Ordinal)
|
|
35
|
+
{
|
|
36
|
+
"stableFrames", "pixelStableFrames", "timeoutSeconds"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
private static readonly HashSet<string> SetFields = new HashSet<string>(StringComparer.Ordinal)
|
|
40
|
+
{
|
|
41
|
+
"target", "property", "value"
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
private static readonly HashSet<string> CollectionFields = new HashSet<string>(StringComparer.Ordinal)
|
|
45
|
+
{
|
|
46
|
+
"target", "mode", "source", "template", "itemHeight"
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
public static List<UiResolvedScenario> Resolve(
|
|
50
|
+
Dictionary<string, object> parameters,
|
|
51
|
+
bool allStates = false)
|
|
52
|
+
{
|
|
53
|
+
parameters = parameters ?? new Dictionary<string, object>();
|
|
54
|
+
var target = RequireString(parameters, "target", "$request");
|
|
55
|
+
var targetPath = NormalizeAssetPath(target, null, "$request.target");
|
|
56
|
+
var requestedState = OptionalString(parameters, "state", "$request");
|
|
57
|
+
|
|
58
|
+
if (allStates && requestedState != null)
|
|
59
|
+
throw Error("ui.state-conflict", "'state' and 'allStates' cannot be used together.", "$request");
|
|
60
|
+
|
|
61
|
+
var requestData = OptionalObject(parameters, "data", "$request.data");
|
|
62
|
+
var hasRequestViewport = parameters.TryGetValue("viewport", out var requestViewportValue);
|
|
63
|
+
var hasRequestSettle = parameters.TryGetValue("settle", out var requestSettleValue);
|
|
64
|
+
|
|
65
|
+
if (targetPath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
|
|
66
|
+
{
|
|
67
|
+
if (requestedState != null && !string.Equals(requestedState, "default", StringComparison.Ordinal))
|
|
68
|
+
throw Error("ui.state-not-found", $"Direct UXML targets only expose the 'default' state, not '{requestedState}'.", "$request.state");
|
|
69
|
+
|
|
70
|
+
var document = LoadVisualTree(targetPath, "$request.target");
|
|
71
|
+
return new List<UiResolvedScenario>
|
|
72
|
+
{
|
|
73
|
+
new UiResolvedScenario(
|
|
74
|
+
targetPath,
|
|
75
|
+
null,
|
|
76
|
+
"default",
|
|
77
|
+
targetPath,
|
|
78
|
+
document,
|
|
79
|
+
MergeData(null, null, requestData),
|
|
80
|
+
Array.Empty<UiSetOperation>(),
|
|
81
|
+
Array.Empty<UiCollectionDefinition>(),
|
|
82
|
+
hasRequestViewport
|
|
83
|
+
? ParseViewport(requestViewportValue, new UiViewport(), "$request.viewport")
|
|
84
|
+
: new UiViewport(),
|
|
85
|
+
hasRequestSettle
|
|
86
|
+
? ParseSettle(requestSettleValue, new UiSettleOptions(), "$request.settle")
|
|
87
|
+
: new UiSettleOptions())
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!targetPath.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase))
|
|
92
|
+
throw Error("ui.target-type", "UI targets must end in '.uxml' or '.ucp-ui.json'.", "$request.target");
|
|
93
|
+
|
|
94
|
+
var fixture = ParseFixture(targetPath);
|
|
95
|
+
var stateNames = SelectStates(fixture, requestedState, allStates);
|
|
96
|
+
var scenarios = new List<UiResolvedScenario>(stateNames.Count);
|
|
97
|
+
foreach (var stateName in stateNames)
|
|
98
|
+
{
|
|
99
|
+
var state = fixture.States[stateName];
|
|
100
|
+
var stateLocation = $"{targetPath}#states.{stateName}";
|
|
101
|
+
var viewport = state.Viewport ?? fixture.Viewport;
|
|
102
|
+
var settle = state.Settle ?? fixture.Settle;
|
|
103
|
+
var set = state.SetOperations ?? fixture.SetOperations;
|
|
104
|
+
var collections = state.Collections ?? fixture.Collections;
|
|
105
|
+
|
|
106
|
+
scenarios.Add(new UiResolvedScenario(
|
|
107
|
+
targetPath,
|
|
108
|
+
targetPath,
|
|
109
|
+
stateName,
|
|
110
|
+
fixture.DocumentPath,
|
|
111
|
+
fixture.Document,
|
|
112
|
+
MergeData(fixture.Data, state.Data, requestData),
|
|
113
|
+
set ?? Array.Empty<UiSetOperation>(),
|
|
114
|
+
collections ?? Array.Empty<UiCollectionDefinition>(),
|
|
115
|
+
hasRequestViewport
|
|
116
|
+
? ParseViewport(requestViewportValue, viewport ?? new UiViewport(), "$request.viewport")
|
|
117
|
+
: viewport ?? new UiViewport(),
|
|
118
|
+
hasRequestSettle
|
|
119
|
+
? ParseSettle(requestSettleValue, settle ?? new UiSettleOptions(), "$request.settle")
|
|
120
|
+
: settle ?? new UiSettleOptions()));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return scenarios;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public static object List(Dictionary<string, object> parameters)
|
|
127
|
+
{
|
|
128
|
+
parameters = parameters ?? new Dictionary<string, object>();
|
|
129
|
+
var limit = OptionalInt(parameters, "limit", 200, "$request.limit", 1, 5000);
|
|
130
|
+
var includePackages = OptionalBool(parameters, "includePackages", false, "$request.includePackages");
|
|
131
|
+
var roots = new List<string>();
|
|
132
|
+
|
|
133
|
+
var requestedPath = OptionalString(parameters, "path", "$request");
|
|
134
|
+
if (requestedPath != null)
|
|
135
|
+
{
|
|
136
|
+
var path = NormalizeAssetPath(requestedPath, null, "$request.path");
|
|
137
|
+
if (!AssetDatabase.IsValidFolder(path))
|
|
138
|
+
throw Error("ui.list-path", $"UI list path is not an asset folder: {path}", "$request.path");
|
|
139
|
+
roots.Add(path);
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
{
|
|
143
|
+
roots.Add("Assets");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (includePackages && !roots.Contains("Packages"))
|
|
147
|
+
roots.Add("Packages");
|
|
148
|
+
|
|
149
|
+
var paths = new HashSet<string>(StringComparer.Ordinal);
|
|
150
|
+
foreach (var guid in AssetDatabase.FindAssets("t:VisualTreeAsset", roots.ToArray()))
|
|
151
|
+
{
|
|
152
|
+
var path = AssetDatabase.GUIDToAssetPath(guid);
|
|
153
|
+
if (path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
|
|
154
|
+
paths.Add(path);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
foreach (var guid in AssetDatabase.FindAssets("t:TextAsset", roots.ToArray()))
|
|
158
|
+
{
|
|
159
|
+
var path = AssetDatabase.GUIDToAssetPath(guid);
|
|
160
|
+
if (path.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase))
|
|
161
|
+
paths.Add(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var ordered = paths.OrderBy(path => path, StringComparer.Ordinal).ToList();
|
|
165
|
+
var items = new List<object>(Math.Min(ordered.Count, limit));
|
|
166
|
+
foreach (var path in ordered.Take(limit))
|
|
167
|
+
{
|
|
168
|
+
if (path.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
|
|
169
|
+
{
|
|
170
|
+
items.Add(new Dictionary<string, object>
|
|
171
|
+
{
|
|
172
|
+
["target"] = path,
|
|
173
|
+
["type"] = "uxml",
|
|
174
|
+
["valid"] = true,
|
|
175
|
+
["states"] = new List<object> { "default" },
|
|
176
|
+
["defaultState"] = "default"
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try
|
|
182
|
+
{
|
|
183
|
+
var fixture = ParseFixture(path);
|
|
184
|
+
items.Add(FixtureSummary(fixture));
|
|
185
|
+
}
|
|
186
|
+
catch (UiScenarioException ex)
|
|
187
|
+
{
|
|
188
|
+
items.Add(new Dictionary<string, object>
|
|
189
|
+
{
|
|
190
|
+
["target"] = path,
|
|
191
|
+
["type"] = "fixture",
|
|
192
|
+
["valid"] = false,
|
|
193
|
+
["diagnostics"] = new List<object> { ex.ToDictionary() }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return new Dictionary<string, object>
|
|
199
|
+
{
|
|
200
|
+
["items"] = items,
|
|
201
|
+
["count"] = ordered.Count,
|
|
202
|
+
["returned"] = items.Count,
|
|
203
|
+
["truncated"] = ordered.Count > items.Count,
|
|
204
|
+
["searchRoots"] = roots.Select(root => (object)root).ToList()
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
public static Dictionary<string, object> ValidateFixture(string fixtureAssetPath)
|
|
209
|
+
{
|
|
210
|
+
var targetPath = NormalizeAssetPath(fixtureAssetPath, null, "$request.target");
|
|
211
|
+
if (!targetPath.EndsWith(".ucp-ui.json", StringComparison.OrdinalIgnoreCase))
|
|
212
|
+
throw Error("ui.target-type", "Fixture paths must end in '.ucp-ui.json'.", "$request.target");
|
|
213
|
+
|
|
214
|
+
var fixture = ParseFixture(targetPath);
|
|
215
|
+
var dependencies = new List<object>
|
|
216
|
+
{
|
|
217
|
+
new Dictionary<string, object>
|
|
218
|
+
{
|
|
219
|
+
["kind"] = "document",
|
|
220
|
+
["path"] = fixture.DocumentPath
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
var seen = new HashSet<string>(StringComparer.Ordinal) { "document\0" + fixture.DocumentPath };
|
|
225
|
+
AddCollectionDependencies(fixture.Collections, null, dependencies, seen);
|
|
226
|
+
foreach (var pair in fixture.States.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
|
227
|
+
AddCollectionDependencies(pair.Value.Collections, pair.Key, dependencies, seen);
|
|
228
|
+
|
|
229
|
+
var result = FixtureSummary(fixture);
|
|
230
|
+
result["dependencies"] = dependencies;
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private static Dictionary<string, object> FixtureSummary(FixtureDefinition fixture)
|
|
235
|
+
{
|
|
236
|
+
var states = fixture.States.Keys.OrderBy(name => name, StringComparer.Ordinal)
|
|
237
|
+
.Select(name => (object)name).ToList();
|
|
238
|
+
return new Dictionary<string, object>
|
|
239
|
+
{
|
|
240
|
+
["target"] = fixture.Path,
|
|
241
|
+
["type"] = "fixture",
|
|
242
|
+
["valid"] = true,
|
|
243
|
+
["schemaVersion"] = 0,
|
|
244
|
+
["document"] = fixture.DocumentPath,
|
|
245
|
+
["states"] = states,
|
|
246
|
+
["defaultState"] = fixture.DefaultState
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private static void AddCollectionDependencies(
|
|
251
|
+
IReadOnlyList<UiCollectionDefinition> collections,
|
|
252
|
+
string state,
|
|
253
|
+
List<object> output,
|
|
254
|
+
HashSet<string> seen)
|
|
255
|
+
{
|
|
256
|
+
if (collections == null)
|
|
257
|
+
return;
|
|
258
|
+
|
|
259
|
+
foreach (var collection in collections)
|
|
260
|
+
{
|
|
261
|
+
var key = (state ?? string.Empty) + "\0" + collection.TemplatePath;
|
|
262
|
+
if (!seen.Add(key))
|
|
263
|
+
continue;
|
|
264
|
+
|
|
265
|
+
output.Add(new Dictionary<string, object>
|
|
266
|
+
{
|
|
267
|
+
["kind"] = "collection-template",
|
|
268
|
+
["path"] = collection.TemplatePath,
|
|
269
|
+
["state"] = state,
|
|
270
|
+
["target"] = collection.Selector
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private static FixtureDefinition ParseFixture(string path)
|
|
276
|
+
{
|
|
277
|
+
var textAsset = AssetDatabase.LoadAssetAtPath<TextAsset>(path);
|
|
278
|
+
if (textAsset == null)
|
|
279
|
+
throw Error("ui.fixture-not-found", $"Could not load fixture as a TextAsset: {path}", path);
|
|
280
|
+
|
|
281
|
+
Dictionary<string, object> root;
|
|
282
|
+
try
|
|
283
|
+
{
|
|
284
|
+
root = MiniJson.Deserialize(textAsset.text) as Dictionary<string, object>;
|
|
285
|
+
}
|
|
286
|
+
catch (Exception ex)
|
|
287
|
+
{
|
|
288
|
+
throw Error("ui.fixture-json", $"Fixture JSON is invalid: {ex.Message}", path);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (root == null)
|
|
292
|
+
throw Error("ui.fixture-root", "Fixture root must be a JSON object.", path);
|
|
293
|
+
|
|
294
|
+
RejectUnknownFields(root, FixtureFields, path);
|
|
295
|
+
var schemaVersion = RequireInt(root, "schemaVersion", path, 0, 0);
|
|
296
|
+
if (schemaVersion != 0)
|
|
297
|
+
throw Error("ui.schema-version", $"Unsupported fixture schemaVersion {schemaVersion}; expected 0.", path + "#schemaVersion");
|
|
298
|
+
|
|
299
|
+
var documentReference = RequireString(root, "document", path);
|
|
300
|
+
var documentPath = NormalizeAssetPath(documentReference, path, path + "#document");
|
|
301
|
+
if (!documentPath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
|
|
302
|
+
throw Error("ui.document-type", "Fixture 'document' must reference a .uxml asset.", path + "#document");
|
|
303
|
+
|
|
304
|
+
var document = LoadVisualTree(documentPath, path + "#document");
|
|
305
|
+
var statesObject = RequireObject(root, "states", path + "#states");
|
|
306
|
+
if (statesObject.Count == 0)
|
|
307
|
+
throw Error("ui.states-empty", "Fixture 'states' must contain at least one state.", path + "#states");
|
|
308
|
+
if (statesObject.Count > MaxFixtureStates)
|
|
309
|
+
{
|
|
310
|
+
throw Error(
|
|
311
|
+
"ui.states-limit",
|
|
312
|
+
$"Fixture 'states' cannot contain more than {MaxFixtureStates} states.",
|
|
313
|
+
path + "#states");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
var fixture = new FixtureDefinition
|
|
317
|
+
{
|
|
318
|
+
Path = path,
|
|
319
|
+
DocumentPath = documentPath,
|
|
320
|
+
Document = document,
|
|
321
|
+
Viewport = root.TryGetValue("viewport", out var viewportValue)
|
|
322
|
+
? ParseViewport(viewportValue, new UiViewport(), path + "#viewport")
|
|
323
|
+
: new UiViewport(),
|
|
324
|
+
Settle = root.TryGetValue("settle", out var settleValue)
|
|
325
|
+
? ParseSettle(settleValue, new UiSettleOptions(), path + "#settle")
|
|
326
|
+
: new UiSettleOptions(),
|
|
327
|
+
Data = OptionalObject(root, "data", path + "#data"),
|
|
328
|
+
SetOperations = root.TryGetValue("set", out var setValue)
|
|
329
|
+
? ParseSetOperations(setValue, path + "#set")
|
|
330
|
+
: Array.Empty<UiSetOperation>(),
|
|
331
|
+
Collections = root.TryGetValue("collections", out var collectionsValue)
|
|
332
|
+
? ParseCollections(collectionsValue, path, path + "#collections")
|
|
333
|
+
: Array.Empty<UiCollectionDefinition>()
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
foreach (var pair in statesObject)
|
|
337
|
+
{
|
|
338
|
+
if (string.IsNullOrWhiteSpace(pair.Key))
|
|
339
|
+
throw Error("ui.state-name", "Fixture state names cannot be empty.", path + "#states");
|
|
340
|
+
if (!(pair.Value is Dictionary<string, object> stateObject))
|
|
341
|
+
throw Error("ui.state-type", $"State '{pair.Key}' must be a JSON object.", path + "#states." + pair.Key);
|
|
342
|
+
|
|
343
|
+
var location = path + "#states." + pair.Key;
|
|
344
|
+
RejectUnknownFields(stateObject, StateFields, location);
|
|
345
|
+
fixture.States.Add(pair.Key, new StateDefinition
|
|
346
|
+
{
|
|
347
|
+
Viewport = stateObject.TryGetValue("viewport", out var stateViewport)
|
|
348
|
+
? ParseViewport(stateViewport, fixture.Viewport, location + ".viewport")
|
|
349
|
+
: null,
|
|
350
|
+
Settle = stateObject.TryGetValue("settle", out var stateSettle)
|
|
351
|
+
? ParseSettle(stateSettle, fixture.Settle, location + ".settle")
|
|
352
|
+
: null,
|
|
353
|
+
Data = OptionalObject(stateObject, "data", location + ".data"),
|
|
354
|
+
SetOperations = stateObject.TryGetValue("set", out var stateSet)
|
|
355
|
+
? ParseSetOperations(stateSet, location + ".set")
|
|
356
|
+
: null,
|
|
357
|
+
Collections = stateObject.TryGetValue("collections", out var stateCollections)
|
|
358
|
+
? ParseCollections(stateCollections, path, location + ".collections")
|
|
359
|
+
: null
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fixture.DefaultState = OptionalString(root, "defaultState", path);
|
|
364
|
+
if (fixture.DefaultState == null)
|
|
365
|
+
{
|
|
366
|
+
if (fixture.States.ContainsKey("default"))
|
|
367
|
+
fixture.DefaultState = "default";
|
|
368
|
+
else if (fixture.States.Count == 1)
|
|
369
|
+
fixture.DefaultState = fixture.States.Keys.First();
|
|
370
|
+
else
|
|
371
|
+
throw Error("ui.default-state", "Fixtures with multiple states must declare 'defaultState'.", path + "#defaultState");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!fixture.States.ContainsKey(fixture.DefaultState))
|
|
375
|
+
throw Error("ui.default-state", $"defaultState '{fixture.DefaultState}' does not exist in 'states'.", path + "#defaultState");
|
|
376
|
+
|
|
377
|
+
return fixture;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private static List<string> SelectStates(FixtureDefinition fixture, string requestedState, bool allStates)
|
|
381
|
+
{
|
|
382
|
+
if (allStates)
|
|
383
|
+
return fixture.States.Keys.OrderBy(name => name, StringComparer.Ordinal).ToList();
|
|
384
|
+
|
|
385
|
+
var stateName = requestedState ?? fixture.DefaultState;
|
|
386
|
+
if (!fixture.States.ContainsKey(stateName))
|
|
387
|
+
throw Error("ui.state-not-found", $"Fixture state '{stateName}' does not exist.", fixture.Path + "#states");
|
|
388
|
+
return new List<string> { stateName };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private static IReadOnlyList<UiSetOperation> ParseSetOperations(object value, string location)
|
|
392
|
+
{
|
|
393
|
+
if (!(value is IList values))
|
|
394
|
+
throw Error("ui.set-type", "'set' must be a JSON array.", location);
|
|
395
|
+
|
|
396
|
+
var result = new List<UiSetOperation>(values.Count);
|
|
397
|
+
for (var i = 0; i < values.Count; i++)
|
|
398
|
+
{
|
|
399
|
+
var itemLocation = $"{location}[{i}]";
|
|
400
|
+
if (!(values[i] is Dictionary<string, object> item))
|
|
401
|
+
throw Error("ui.set-item-type", "Each 'set' entry must be an object.", itemLocation);
|
|
402
|
+
|
|
403
|
+
RejectUnknownFields(item, SetFields, itemLocation);
|
|
404
|
+
var selector = RequireString(item, "target", itemLocation);
|
|
405
|
+
ValidateSelector(selector, itemLocation + ".target");
|
|
406
|
+
var property = RequireString(item, "property", itemLocation);
|
|
407
|
+
ValidateSetProperty(property, itemLocation + ".property");
|
|
408
|
+
if (!item.TryGetValue("value", out var setValue))
|
|
409
|
+
throw Error("ui.missing-field", "Missing required field 'value'.", itemLocation);
|
|
410
|
+
|
|
411
|
+
result.Add(new UiSetOperation(selector, property, NormalizeJsonValue(setValue), itemLocation));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private static IReadOnlyList<UiCollectionDefinition> ParseCollections(
|
|
418
|
+
object value,
|
|
419
|
+
string fixturePath,
|
|
420
|
+
string location)
|
|
421
|
+
{
|
|
422
|
+
if (!(value is IList values))
|
|
423
|
+
throw Error("ui.collections-type", "'collections' must be a JSON array.", location);
|
|
424
|
+
|
|
425
|
+
var result = new List<UiCollectionDefinition>(values.Count);
|
|
426
|
+
var targets = new HashSet<string>(StringComparer.Ordinal);
|
|
427
|
+
for (var i = 0; i < values.Count; i++)
|
|
428
|
+
{
|
|
429
|
+
var itemLocation = $"{location}[{i}]";
|
|
430
|
+
if (!(values[i] is Dictionary<string, object> item))
|
|
431
|
+
throw Error("ui.collection-item-type", "Each collection entry must be an object.", itemLocation);
|
|
432
|
+
|
|
433
|
+
RejectUnknownFields(item, CollectionFields, itemLocation);
|
|
434
|
+
var selector = RequireString(item, "target", itemLocation);
|
|
435
|
+
ValidateSelector(selector, itemLocation + ".target");
|
|
436
|
+
if (!targets.Add(selector))
|
|
437
|
+
throw Error("ui.collection-duplicate", $"Collection target '{selector}' is configured more than once in one scope.", itemLocation + ".target");
|
|
438
|
+
|
|
439
|
+
var modeText = RequireString(item, "mode", itemLocation);
|
|
440
|
+
UiCollectionMode mode;
|
|
441
|
+
if (string.Equals(modeText, "repeat", StringComparison.Ordinal))
|
|
442
|
+
mode = UiCollectionMode.Repeat;
|
|
443
|
+
else if (string.Equals(modeText, "list-view", StringComparison.Ordinal))
|
|
444
|
+
mode = UiCollectionMode.ListView;
|
|
445
|
+
else
|
|
446
|
+
throw Error("ui.collection-mode", "Collection mode must be 'repeat' or 'list-view'.", itemLocation + ".mode");
|
|
447
|
+
|
|
448
|
+
var source = RequireString(item, "source", itemLocation);
|
|
449
|
+
ValidateJsonPointer(source, itemLocation + ".source");
|
|
450
|
+
var templateReference = RequireString(item, "template", itemLocation);
|
|
451
|
+
var templatePath = NormalizeAssetPath(templateReference, fixturePath, itemLocation + ".template");
|
|
452
|
+
if (!templatePath.EndsWith(".uxml", StringComparison.OrdinalIgnoreCase))
|
|
453
|
+
throw Error("ui.template-type", "Collection templates must reference a .uxml asset.", itemLocation + ".template");
|
|
454
|
+
|
|
455
|
+
float? itemHeight = null;
|
|
456
|
+
if (item.TryGetValue("itemHeight", out var heightValue))
|
|
457
|
+
{
|
|
458
|
+
var height = NumberAsDouble(heightValue, itemLocation + ".itemHeight");
|
|
459
|
+
if (!IsFinite(height) || height <= 0 || height > 10000)
|
|
460
|
+
throw Error("ui.item-height", "itemHeight must be finite and between 0 and 10000.", itemLocation + ".itemHeight");
|
|
461
|
+
itemHeight = (float)height;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
result.Add(new UiCollectionDefinition(
|
|
465
|
+
selector,
|
|
466
|
+
mode,
|
|
467
|
+
source,
|
|
468
|
+
templatePath,
|
|
469
|
+
LoadVisualTree(templatePath, itemLocation + ".template"),
|
|
470
|
+
itemHeight,
|
|
471
|
+
itemLocation));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private static UiViewport ParseViewport(object value, UiViewport fallback, string location)
|
|
478
|
+
{
|
|
479
|
+
if (!(value is Dictionary<string, object> viewport))
|
|
480
|
+
throw Error("ui.viewport-type", "viewport must be an object.", location);
|
|
481
|
+
RejectUnknownFields(viewport, ViewportFields, location);
|
|
482
|
+
|
|
483
|
+
var width = OptionalInt(viewport, "width", fallback.Width, location + ".width", 1, 8192);
|
|
484
|
+
var height = OptionalInt(viewport, "height", fallback.Height, location + ".height", 1, 8192);
|
|
485
|
+
if ((long)width * height > MaxViewportPixelArea)
|
|
486
|
+
{
|
|
487
|
+
throw Error(
|
|
488
|
+
"ui.viewport-area",
|
|
489
|
+
$"Viewport area cannot exceed {MaxViewportPixelArea} pixels.",
|
|
490
|
+
location);
|
|
491
|
+
}
|
|
492
|
+
return new UiViewport(width, height);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private static UiSettleOptions ParseSettle(object value, UiSettleOptions fallback, string location)
|
|
496
|
+
{
|
|
497
|
+
if (!(value is Dictionary<string, object> settle))
|
|
498
|
+
throw Error("ui.settle-type", "settle must be an object.", location);
|
|
499
|
+
RejectUnknownFields(settle, SettleFields, location);
|
|
500
|
+
|
|
501
|
+
var stableFrames = OptionalInt(settle, "stableFrames", fallback.StableFrames, location + ".stableFrames", 1, 60);
|
|
502
|
+
var pixelFrames = OptionalInt(settle, "pixelStableFrames", fallback.PixelStableFrames, location + ".pixelStableFrames", 1, 60);
|
|
503
|
+
var timeout = settle.TryGetValue("timeoutSeconds", out var timeoutValue)
|
|
504
|
+
? NumberAsDouble(timeoutValue, location + ".timeoutSeconds")
|
|
505
|
+
: fallback.TimeoutSeconds;
|
|
506
|
+
if (!IsFinite(timeout) || timeout < 0.1 || timeout > 300)
|
|
507
|
+
throw Error("ui.settle-timeout", "timeoutSeconds must be finite and between 0.1 and 300.", location + ".timeoutSeconds");
|
|
508
|
+
|
|
509
|
+
return new UiSettleOptions(stableFrames, pixelFrames, timeout);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private static Dictionary<string, object> MergeData(
|
|
513
|
+
Dictionary<string, object> fixtureData,
|
|
514
|
+
Dictionary<string, object> stateData,
|
|
515
|
+
Dictionary<string, object> requestData)
|
|
516
|
+
{
|
|
517
|
+
var merged = CloneObject(fixtureData) ?? new Dictionary<string, object>();
|
|
518
|
+
MergeInto(merged, stateData);
|
|
519
|
+
MergeInto(merged, requestData);
|
|
520
|
+
return merged;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private static void MergeInto(Dictionary<string, object> target, Dictionary<string, object> overlay)
|
|
524
|
+
{
|
|
525
|
+
if (overlay == null)
|
|
526
|
+
return;
|
|
527
|
+
|
|
528
|
+
foreach (var pair in overlay)
|
|
529
|
+
{
|
|
530
|
+
if (pair.Value is Dictionary<string, object> overlayObject &&
|
|
531
|
+
target.TryGetValue(pair.Key, out var current) && current is Dictionary<string, object> currentObject)
|
|
532
|
+
{
|
|
533
|
+
MergeInto(currentObject, overlayObject);
|
|
534
|
+
}
|
|
535
|
+
else
|
|
536
|
+
{
|
|
537
|
+
target[pair.Key] = NormalizeJsonValue(pair.Value);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private static Dictionary<string, object> CloneObject(Dictionary<string, object> value)
|
|
543
|
+
{
|
|
544
|
+
if (value == null)
|
|
545
|
+
return null;
|
|
546
|
+
|
|
547
|
+
var result = new Dictionary<string, object>(StringComparer.Ordinal);
|
|
548
|
+
foreach (var pair in value)
|
|
549
|
+
result[pair.Key] = NormalizeJsonValue(pair.Value);
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
internal static object NormalizeJsonValue(object value)
|
|
554
|
+
{
|
|
555
|
+
if (value is long integer && integer >= int.MinValue && integer <= int.MaxValue)
|
|
556
|
+
return (int)integer;
|
|
557
|
+
if (value is Dictionary<string, object> dictionary)
|
|
558
|
+
return CloneObject(dictionary);
|
|
559
|
+
if (value is IList list)
|
|
560
|
+
{
|
|
561
|
+
var normalized = new List<object>(list.Count);
|
|
562
|
+
foreach (var item in list)
|
|
563
|
+
normalized.Add(NormalizeJsonValue(item));
|
|
564
|
+
return normalized;
|
|
565
|
+
}
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private static VisualTreeAsset LoadVisualTree(string path, string location)
|
|
570
|
+
{
|
|
571
|
+
var asset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(path);
|
|
572
|
+
if (asset == null)
|
|
573
|
+
throw Error("ui.uxml-not-found", $"Could not load UXML asset: {path}", location);
|
|
574
|
+
return asset;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
internal static string NormalizeAssetPath(string input, string relativeToFixture, string location)
|
|
578
|
+
{
|
|
579
|
+
if (string.IsNullOrWhiteSpace(input))
|
|
580
|
+
throw Error("ui.path-empty", "Asset path cannot be empty.", location);
|
|
581
|
+
|
|
582
|
+
var normalizedInput = input.Trim().Replace('\\', '/');
|
|
583
|
+
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
|
584
|
+
string absolute;
|
|
585
|
+
|
|
586
|
+
if (Path.IsPathRooted(normalizedInput))
|
|
587
|
+
{
|
|
588
|
+
absolute = NormalizePathSegments(normalizedInput);
|
|
589
|
+
}
|
|
590
|
+
else
|
|
591
|
+
{
|
|
592
|
+
string projectRelative;
|
|
593
|
+
if (normalizedInput.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) ||
|
|
594
|
+
normalizedInput.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) ||
|
|
595
|
+
string.Equals(normalizedInput, "Assets", StringComparison.OrdinalIgnoreCase) ||
|
|
596
|
+
string.Equals(normalizedInput, "Packages", StringComparison.OrdinalIgnoreCase))
|
|
597
|
+
{
|
|
598
|
+
projectRelative = normalizedInput;
|
|
599
|
+
}
|
|
600
|
+
else if (relativeToFixture != null)
|
|
601
|
+
{
|
|
602
|
+
var baseDirectory = Path.GetDirectoryName(relativeToFixture.Replace('/', Path.DirectorySeparatorChar))
|
|
603
|
+
?? string.Empty;
|
|
604
|
+
projectRelative = Path.Combine(baseDirectory, normalizedInput.Replace('/', Path.DirectorySeparatorChar));
|
|
605
|
+
}
|
|
606
|
+
else
|
|
607
|
+
{
|
|
608
|
+
projectRelative = normalizedInput;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
absolute = NormalizePathSegments(Path.Combine(projectRoot, projectRelative));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
var rootWithSeparator = projectRoot.Replace('\\', '/').TrimEnd('/') + "/";
|
|
615
|
+
if (!absolute.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
|
616
|
+
throw Error("ui.path-outside-project", "UI asset paths must stay inside the Unity project.", location);
|
|
617
|
+
|
|
618
|
+
var relative = absolute.Substring(rootWithSeparator.Length).Replace('\\', '/');
|
|
619
|
+
if (!(relative.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) ||
|
|
620
|
+
relative.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase) ||
|
|
621
|
+
string.Equals(relative, "Assets", StringComparison.OrdinalIgnoreCase) ||
|
|
622
|
+
string.Equals(relative, "Packages", StringComparison.OrdinalIgnoreCase)))
|
|
623
|
+
{
|
|
624
|
+
throw Error("ui.path-root", "UI asset paths must be under Assets or Packages.", location);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return relative;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private static string NormalizePathSegments(string absolutePath)
|
|
631
|
+
{
|
|
632
|
+
// Unity's Path.GetFullPath remaps virtual Packages paths into
|
|
633
|
+
// Library/PackageCache. Collapse dot segments without resolving that
|
|
634
|
+
// alias so validation and AssetDatabase receive the virtual path.
|
|
635
|
+
var path = absolutePath.Replace('\\', '/');
|
|
636
|
+
var root = Path.GetPathRoot(path).Replace('\\', '/');
|
|
637
|
+
var segments = new List<string>();
|
|
638
|
+
foreach (var segment in path.Substring(root.Length).Split('/'))
|
|
639
|
+
{
|
|
640
|
+
if (segment.Length == 0 || segment == ".")
|
|
641
|
+
continue;
|
|
642
|
+
if (segment == "..")
|
|
643
|
+
{
|
|
644
|
+
if (segments.Count > 0)
|
|
645
|
+
segments.RemoveAt(segments.Count - 1);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
segments.Add(segment);
|
|
649
|
+
}
|
|
650
|
+
return root + string.Join("/", segments);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
internal static void ValidateSelector(string selector, string location)
|
|
654
|
+
{
|
|
655
|
+
if (string.Equals(selector, ":root", StringComparison.Ordinal))
|
|
656
|
+
return;
|
|
657
|
+
if (selector.Length < 2 || (selector[0] != '#' && selector[0] != '.'))
|
|
658
|
+
throw Error("ui.selector-syntax", "Selectors must be ':root', '#name', or '.class'.", location);
|
|
659
|
+
|
|
660
|
+
for (var i = 1; i < selector.Length; i++)
|
|
661
|
+
{
|
|
662
|
+
var c = selector[i];
|
|
663
|
+
if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-'))
|
|
664
|
+
throw Error("ui.selector-syntax", "Selector names may contain only letters, digits, '_' and '-'.", location);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private static void ValidateSetProperty(string property, string location)
|
|
669
|
+
{
|
|
670
|
+
if (property == "text" || property == "value" || property == "enabled" ||
|
|
671
|
+
property == "display" || property == "visibility" || property == "tooltip")
|
|
672
|
+
return;
|
|
673
|
+
|
|
674
|
+
if (property.StartsWith("class:", StringComparison.Ordinal) && property.Length > "class:".Length)
|
|
675
|
+
{
|
|
676
|
+
ValidateSelector("." + property.Substring("class:".Length), location);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
throw Error("ui.set-property", $"Property '{property}' is not allowed in UI fixtures.", location);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private static void ValidateJsonPointer(string pointer, string location)
|
|
684
|
+
{
|
|
685
|
+
if (pointer.Length == 0)
|
|
686
|
+
return;
|
|
687
|
+
if (pointer[0] != '/')
|
|
688
|
+
throw Error("ui.source-path", "Collection source must be an RFC 6901-style JSON pointer such as '/items'.", location);
|
|
689
|
+
|
|
690
|
+
for (var i = 0; i < pointer.Length; i++)
|
|
691
|
+
{
|
|
692
|
+
if (pointer[i] != '~')
|
|
693
|
+
continue;
|
|
694
|
+
if (i + 1 >= pointer.Length || (pointer[i + 1] != '0' && pointer[i + 1] != '1'))
|
|
695
|
+
throw Error("ui.source-path", "JSON pointer '~' escapes must be '~0' or '~1'.", location);
|
|
696
|
+
i++;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private static void RejectUnknownFields(
|
|
701
|
+
Dictionary<string, object> value,
|
|
702
|
+
HashSet<string> allowed,
|
|
703
|
+
string location)
|
|
704
|
+
{
|
|
705
|
+
foreach (var key in value.Keys)
|
|
706
|
+
{
|
|
707
|
+
if (!allowed.Contains(key))
|
|
708
|
+
throw Error("ui.unknown-field", $"Unknown field '{key}'.", location + "." + key);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private static Dictionary<string, object> RequireObject(
|
|
713
|
+
Dictionary<string, object> value,
|
|
714
|
+
string key,
|
|
715
|
+
string location)
|
|
716
|
+
{
|
|
717
|
+
if (!value.TryGetValue(key, out var field))
|
|
718
|
+
throw Error("ui.missing-field", $"Missing required field '{key}'.", location);
|
|
719
|
+
if (!(field is Dictionary<string, object> result))
|
|
720
|
+
throw Error("ui.field-type", $"Field '{key}' must be an object.", location);
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private static Dictionary<string, object> OptionalObject(
|
|
725
|
+
Dictionary<string, object> value,
|
|
726
|
+
string key,
|
|
727
|
+
string location)
|
|
728
|
+
{
|
|
729
|
+
if (!value.TryGetValue(key, out var field))
|
|
730
|
+
return null;
|
|
731
|
+
if (!(field is Dictionary<string, object> result))
|
|
732
|
+
throw Error("ui.field-type", $"Field '{key}' must be an object.", location);
|
|
733
|
+
return CloneObject(result);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private static string RequireString(Dictionary<string, object> value, string key, string location)
|
|
737
|
+
{
|
|
738
|
+
var result = OptionalString(value, key, location);
|
|
739
|
+
if (result == null)
|
|
740
|
+
throw Error("ui.missing-field", $"Missing required string field '{key}'.", location);
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private static string OptionalString(Dictionary<string, object> value, string key, string location)
|
|
745
|
+
{
|
|
746
|
+
if (!value.TryGetValue(key, out var field))
|
|
747
|
+
return null;
|
|
748
|
+
if (!(field is string result) || string.IsNullOrWhiteSpace(result))
|
|
749
|
+
throw Error("ui.field-type", $"Field '{key}' must be a non-empty string.", location + "." + key);
|
|
750
|
+
return result;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private static int RequireInt(
|
|
754
|
+
Dictionary<string, object> value,
|
|
755
|
+
string key,
|
|
756
|
+
string location,
|
|
757
|
+
int minimum,
|
|
758
|
+
int maximum)
|
|
759
|
+
{
|
|
760
|
+
if (!value.TryGetValue(key, out var field))
|
|
761
|
+
throw Error("ui.missing-field", $"Missing required integer field '{key}'.", location);
|
|
762
|
+
return CheckedInt(field, location + "." + key, minimum, maximum);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private static int OptionalInt(
|
|
766
|
+
Dictionary<string, object> value,
|
|
767
|
+
string key,
|
|
768
|
+
int fallback,
|
|
769
|
+
string location,
|
|
770
|
+
int minimum,
|
|
771
|
+
int maximum)
|
|
772
|
+
{
|
|
773
|
+
return value.TryGetValue(key, out var field)
|
|
774
|
+
? CheckedInt(field, location, minimum, maximum)
|
|
775
|
+
: fallback;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private static int CheckedInt(object value, string location, int minimum, int maximum)
|
|
779
|
+
{
|
|
780
|
+
long integer;
|
|
781
|
+
if (value is int intValue)
|
|
782
|
+
integer = intValue;
|
|
783
|
+
else if (value is long longValue)
|
|
784
|
+
integer = longValue;
|
|
785
|
+
else
|
|
786
|
+
throw Error("ui.integer-type", "Expected an integer.", location);
|
|
787
|
+
|
|
788
|
+
if (integer < minimum || integer > maximum)
|
|
789
|
+
throw Error("ui.integer-range", $"Integer must be between {minimum} and {maximum}.", location);
|
|
790
|
+
return (int)integer;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private static bool OptionalBool(
|
|
794
|
+
Dictionary<string, object> value,
|
|
795
|
+
string key,
|
|
796
|
+
bool fallback,
|
|
797
|
+
string location)
|
|
798
|
+
{
|
|
799
|
+
if (!value.TryGetValue(key, out var field))
|
|
800
|
+
return fallback;
|
|
801
|
+
if (!(field is bool result))
|
|
802
|
+
throw Error("ui.boolean-type", "Expected a boolean.", location);
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private static double NumberAsDouble(object value, string location)
|
|
807
|
+
{
|
|
808
|
+
if (value is int integer)
|
|
809
|
+
return integer;
|
|
810
|
+
if (value is long longInteger)
|
|
811
|
+
return longInteger;
|
|
812
|
+
if (value is float single)
|
|
813
|
+
return single;
|
|
814
|
+
if (value is double number)
|
|
815
|
+
return number;
|
|
816
|
+
throw Error("ui.number-type", "Expected a number.", location);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private static bool IsFinite(double value)
|
|
820
|
+
{
|
|
821
|
+
return !double.IsNaN(value) && !double.IsInfinity(value);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private static UiScenarioException Error(string code, string message, string location)
|
|
825
|
+
{
|
|
826
|
+
return new UiScenarioException(code, message, location);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
private sealed class FixtureDefinition
|
|
830
|
+
{
|
|
831
|
+
public string Path;
|
|
832
|
+
public string DocumentPath;
|
|
833
|
+
public VisualTreeAsset Document;
|
|
834
|
+
public string DefaultState;
|
|
835
|
+
public UiViewport Viewport;
|
|
836
|
+
public UiSettleOptions Settle;
|
|
837
|
+
public Dictionary<string, object> Data;
|
|
838
|
+
public IReadOnlyList<UiSetOperation> SetOperations;
|
|
839
|
+
public IReadOnlyList<UiCollectionDefinition> Collections;
|
|
840
|
+
public readonly Dictionary<string, StateDefinition> States =
|
|
841
|
+
new Dictionary<string, StateDefinition>(StringComparer.Ordinal);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private sealed class StateDefinition
|
|
845
|
+
{
|
|
846
|
+
public UiViewport Viewport;
|
|
847
|
+
public UiSettleOptions Settle;
|
|
848
|
+
public Dictionary<string, object> Data;
|
|
849
|
+
public IReadOnlyList<UiSetOperation> SetOperations;
|
|
850
|
+
public IReadOnlyList<UiCollectionDefinition> Collections;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
#endif
|