@mflrevan/ucp 0.4.6 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,6 +80,47 @@ namespace UCP.Bridge
80
80
  return CreateReimportResult(requestedPath, assetPath, true, false, null);
81
81
  }
82
82
 
83
+ public static Dictionary<string, object> ReimportRecursive(string requestedPath, bool forceSynchronous = true)
84
+ {
85
+ var assetPath = GetPrimaryAssetPath(requestedPath);
86
+ if (string.IsNullOrEmpty(assetPath))
87
+ throw new ArgumentException("Missing 'path' parameter");
88
+ if (!IsProjectAssetPath(assetPath))
89
+ throw new ArgumentException($"Path is not under Assets/ or Packages/: {requestedPath}");
90
+
91
+ if (!AssetDatabase.IsValidFolder(assetPath))
92
+ return Reimport(requestedPath, forceSynchronous);
93
+
94
+ var options = ImportAssetOptions.ForceUpdate;
95
+ if (forceSynchronous)
96
+ options |= ImportAssetOptions.ForceSynchronousImport;
97
+
98
+ var guidResults = AssetDatabase.FindAssets(string.Empty, new[] { assetPath });
99
+ var importedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
100
+
101
+ foreach (var guid in guidResults)
102
+ {
103
+ var childPath = AssetDatabase.GUIDToAssetPath(guid);
104
+ if (string.IsNullOrEmpty(childPath) || AssetDatabase.IsValidFolder(childPath))
105
+ continue;
106
+
107
+ AssetDatabase.ImportAsset(childPath, options);
108
+ importedPaths.Add(childPath);
109
+ RecordReimport(childPath);
110
+ }
111
+
112
+ return new Dictionary<string, object>
113
+ {
114
+ ["requestedPath"] = requestedPath,
115
+ ["assetPath"] = assetPath,
116
+ ["recursive"] = true,
117
+ ["requested"] = importedPaths.Count,
118
+ ["reimported"] = importedPaths.Count,
119
+ ["skipped"] = 0,
120
+ ["paths"] = new List<object>(importedPaths)
121
+ };
122
+ }
123
+
83
124
  public static Dictionary<string, object> SaveImporterSettings(string requestedPath, AssetImporter importer, bool noReimport)
84
125
  {
85
126
  if (importer == null)
@@ -18,7 +18,10 @@ namespace UCP.Bridge
18
18
  {
19
19
  var parameters = ParseParameters(paramsJson);
20
20
  var requestedPath = RequirePath(parameters);
21
- return AssetImportSupport.Reimport(requestedPath);
21
+ var recursive = TryGetOptionalBool(parameters, "recursive");
22
+ return recursive
23
+ ? AssetImportSupport.ReimportRecursive(requestedPath)
24
+ : AssetImportSupport.Reimport(requestedPath);
22
25
  }
23
26
 
24
27
  private static object HandleRead(string paramsJson)
@@ -45,6 +45,27 @@ namespace UCP.Bridge
45
45
  return RecordLog(level, message, stackTrace);
46
46
  }
47
47
 
48
+ public static long GetLatestId()
49
+ {
50
+ lock (s_historyLock)
51
+ {
52
+ return s_history.Count == 0 ? 0 : s_history[s_history.Count - 1].Id;
53
+ }
54
+ }
55
+
56
+ public static Dictionary<string, object> BuildStatusSummary(long afterId = 0)
57
+ {
58
+ lock (s_historyLock)
59
+ {
60
+ var ordered = s_history
61
+ .Where(entry => entry.Id > afterId)
62
+ .OrderBy(entry => entry.Id)
63
+ .ToList();
64
+
65
+ return BuildStatusResult(ordered, afterId);
66
+ }
67
+ }
68
+
48
69
  private static object HandleTail(string paramsJson)
49
70
  {
50
71
  var query = ParseQuery(paramsJson, includePattern: false);
@@ -77,82 +98,12 @@ namespace UCP.Bridge
77
98
 
78
99
  private static object HandleStatus(string paramsJson)
79
100
  {
80
- lock (s_historyLock)
81
- {
82
- var ordered = s_history.OrderBy(entry => entry.Id).ToList();
83
- var byLevel = new Dictionary<string, object>
84
- {
85
- ["info"] = ordered.Count(entry => entry.Level == "info"),
86
- ["warning"] = ordered.Count(entry => entry.Level == "warning"),
87
- ["error"] = ordered.Count(entry => entry.Level == "error"),
88
- ["exception"] = ordered.Count(entry => entry.Level == "exception")
89
- };
90
-
91
- var grouped = ordered
92
- .GroupBy(entry => $"{entry.Level}|{Fingerprint(entry.Message)}")
93
- .Select(group =>
94
- {
95
- var first = group.First();
96
- var last = group.Last();
97
- return new Dictionary<string, object>
98
- {
99
- ["level"] = first.Level,
100
- ["fingerprint"] = Fingerprint(first.Message),
101
- ["sampleMessage"] = Preview(first.Message, MaxPreviewLength),
102
- ["count"] = group.Count(),
103
- ["firstTimestamp"] = first.Timestamp,
104
- ["lastTimestamp"] = last.Timestamp,
105
- ["latestId"] = last.Id
106
- };
107
- })
108
- .OrderByDescending(entry => Convert.ToInt32(entry["count"]))
109
- .ThenBy(entry => entry["sampleMessage"].ToString())
110
- .ToList();
111
-
112
- var result = new Dictionary<string, object>
113
- {
114
- ["total"] = ordered.Count,
115
- ["byLevel"] = byLevel,
116
- ["uniqueCount"] = grouped.Count,
117
- ["topCategories"] = grouped.Take(8).Cast<object>().ToList()
118
- };
119
-
120
- if (ordered.Count > 0)
121
- {
122
- var first = ordered.First();
123
- var last = ordered.Last();
124
- result["firstTimestamp"] = first.Timestamp;
125
- result["lastTimestamp"] = last.Timestamp;
126
- result["historyWindowSeconds"] = Math.Max(0d, (last.TimestampUtc - first.TimestampUtc).TotalSeconds);
127
- result["latestId"] = last.Id;
128
- }
129
-
130
- var playSession = PlayModeController.GetSessionSnapshot();
131
- result["play"] = SerializePlaySession(playSession);
132
-
133
- if (playSession.LastEnteredPlayAtUtc.HasValue)
134
- {
135
- var sessionEnd = playSession.Playing
136
- ? DateTime.UtcNow
137
- : (playSession.LastExitedPlayAtUtc ?? DateTime.UtcNow);
138
- var sessionLogs = ordered
139
- .Where(entry => entry.TimestampUtc >= playSession.LastEnteredPlayAtUtc.Value
140
- && entry.TimestampUtc <= sessionEnd)
141
- .ToList();
142
-
143
- result["lastPlayWindow"] = new Dictionary<string, object>
144
- {
145
- ["startedAt"] = playSession.LastEnteredPlayAtUtc.Value.ToString("o"),
146
- ["endedAt"] = sessionEnd.ToString("o"),
147
- ["durationSeconds"] = Math.Max(0d, (sessionEnd - playSession.LastEnteredPlayAtUtc.Value).TotalSeconds),
148
- ["total"] = sessionLogs.Count,
149
- ["warnings"] = sessionLogs.Count(entry => entry.Level == "warning"),
150
- ["errors"] = sessionLogs.Count(entry => entry.Level == "error" || entry.Level == "exception")
151
- };
152
- }
101
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
102
+ long afterId = 0;
103
+ if (p != null && p.TryGetValue("afterId", out var afterIdObj) && afterIdObj != null)
104
+ afterId = Math.Max(0, Convert.ToInt64(afterIdObj));
153
105
 
154
- return result;
155
- }
106
+ return BuildStatusSummary(afterId);
156
107
  }
157
108
 
158
109
  private static Dictionary<string, object> RecordLog(string level, string message, string stackTrace)
@@ -257,6 +208,83 @@ namespace UCP.Bridge
257
208
  };
258
209
  }
259
210
 
211
+ private static Dictionary<string, object> BuildStatusResult(List<LogRecord> ordered, long afterId)
212
+ {
213
+ var byLevel = new Dictionary<string, object>
214
+ {
215
+ ["info"] = ordered.Count(entry => entry.Level == "info"),
216
+ ["warning"] = ordered.Count(entry => entry.Level == "warning"),
217
+ ["error"] = ordered.Count(entry => entry.Level == "error"),
218
+ ["exception"] = ordered.Count(entry => entry.Level == "exception")
219
+ };
220
+
221
+ var grouped = ordered
222
+ .GroupBy(entry => $"{entry.Level}|{Fingerprint(entry.Message)}")
223
+ .Select(group =>
224
+ {
225
+ var first = group.First();
226
+ var last = group.Last();
227
+ return new Dictionary<string, object>
228
+ {
229
+ ["level"] = first.Level,
230
+ ["fingerprint"] = Fingerprint(first.Message),
231
+ ["sampleMessage"] = Preview(first.Message, MaxPreviewLength),
232
+ ["count"] = group.Count(),
233
+ ["firstTimestamp"] = first.Timestamp,
234
+ ["lastTimestamp"] = last.Timestamp,
235
+ ["latestId"] = last.Id
236
+ };
237
+ })
238
+ .OrderByDescending(entry => Convert.ToInt32(entry["count"]))
239
+ .ThenBy(entry => entry["sampleMessage"].ToString())
240
+ .ToList();
241
+
242
+ var result = new Dictionary<string, object>
243
+ {
244
+ ["afterId"] = afterId,
245
+ ["total"] = ordered.Count,
246
+ ["byLevel"] = byLevel,
247
+ ["uniqueCount"] = grouped.Count,
248
+ ["topCategories"] = grouped.Take(8).Cast<object>().ToList()
249
+ };
250
+
251
+ if (ordered.Count > 0)
252
+ {
253
+ var first = ordered.First();
254
+ var last = ordered.Last();
255
+ result["firstTimestamp"] = first.Timestamp;
256
+ result["lastTimestamp"] = last.Timestamp;
257
+ result["historyWindowSeconds"] = Math.Max(0d, (last.TimestampUtc - first.TimestampUtc).TotalSeconds);
258
+ result["latestId"] = last.Id;
259
+ }
260
+
261
+ var playSession = PlayModeController.GetSessionSnapshot();
262
+ result["play"] = SerializePlaySession(playSession);
263
+
264
+ if (playSession.LastEnteredPlayAtUtc.HasValue)
265
+ {
266
+ var sessionEnd = playSession.Playing
267
+ ? DateTime.UtcNow
268
+ : (playSession.LastExitedPlayAtUtc ?? DateTime.UtcNow);
269
+ var sessionLogs = ordered
270
+ .Where(entry => entry.TimestampUtc >= playSession.LastEnteredPlayAtUtc.Value
271
+ && entry.TimestampUtc <= sessionEnd)
272
+ .ToList();
273
+
274
+ result["lastPlayWindow"] = new Dictionary<string, object>
275
+ {
276
+ ["startedAt"] = playSession.LastEnteredPlayAtUtc.Value.ToString("o"),
277
+ ["endedAt"] = sessionEnd.ToString("o"),
278
+ ["durationSeconds"] = Math.Max(0d, (sessionEnd - playSession.LastEnteredPlayAtUtc.Value).TotalSeconds),
279
+ ["total"] = sessionLogs.Count,
280
+ ["warnings"] = sessionLogs.Count(entry => entry.Level == "warning"),
281
+ ["errors"] = sessionLogs.Count(entry => entry.Level == "error" || entry.Level == "exception")
282
+ };
283
+ }
284
+
285
+ return result;
286
+ }
287
+
260
288
  private static Dictionary<string, object> SerializeSummary(LogRecord entry)
261
289
  {
262
290
  return new Dictionary<string, object>
@@ -0,0 +1,163 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using UnityEditor;
4
+ using UnityEngine;
5
+
6
+ namespace UCP.Bridge
7
+ {
8
+ /// <summary>
9
+ /// Bridge controller for reference search - used as a fallback when native Rust
10
+ /// indexing is not available (non-Force-Text projects) or for correctness verification.
11
+ /// </summary>
12
+ public static class ReferenceController
13
+ {
14
+ public static void Register(CommandRouter router)
15
+ {
16
+ router.Register("references/find", HandleFind);
17
+ router.Register("references/serialization-status", HandleSerializationStatus);
18
+ }
19
+
20
+ private static object HandleSerializationStatus(string paramsJson)
21
+ {
22
+ var settings = new SerializedObject(
23
+ AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/EditorSettings.asset")[0]);
24
+ try
25
+ {
26
+ settings.Update();
27
+ var modeProp = settings.FindProperty("m_SerializationMode");
28
+ int mode = modeProp != null ? modeProp.intValue : -1;
29
+
30
+ return new Dictionary<string, object>
31
+ {
32
+ { "serializationMode", mode },
33
+ { "forceText", mode == 2 },
34
+ { "visibleMetaFiles", EditorSettings.externalVersionControl == "Visible Meta Files" }
35
+ };
36
+ }
37
+ finally
38
+ {
39
+ settings.Dispose();
40
+ }
41
+ }
42
+
43
+ private static object HandleFind(string paramsJson)
44
+ {
45
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
46
+ if (p == null)
47
+ throw new ArgumentException("Invalid parameters");
48
+
49
+ string targetGuid = null;
50
+ long targetFileId = 0;
51
+ int maxResults = 100;
52
+
53
+ if (p.TryGetValue("guid", out var gObj) && gObj != null)
54
+ targetGuid = gObj.ToString();
55
+ if (p.TryGetValue("fileId", out var fObj) && fObj != null)
56
+ targetFileId = Convert.ToInt64(fObj);
57
+ if (p.TryGetValue("maxResults", out var mObj) && mObj != null)
58
+ maxResults = Convert.ToInt32(mObj);
59
+
60
+ if (string.IsNullOrEmpty(targetGuid))
61
+ throw new ArgumentException("Missing 'guid' parameter");
62
+
63
+ var targetPath = AssetDatabase.GUIDToAssetPath(targetGuid);
64
+ var references = new List<object>();
65
+
66
+ // Phase 1: Find all assets that depend on the target using AssetDatabase
67
+ var allAssets = AssetDatabase.GetAllAssetPaths();
68
+ var dependentPaths = new List<string>();
69
+
70
+ foreach (var assetPath in allAssets)
71
+ {
72
+ if (!assetPath.StartsWith("Assets/") && !assetPath.StartsWith("Packages/"))
73
+ continue;
74
+
75
+ var deps = AssetDatabase.GetDependencies(assetPath, false);
76
+ foreach (var dep in deps)
77
+ {
78
+ if (dep == targetPath)
79
+ {
80
+ dependentPaths.Add(assetPath);
81
+ break;
82
+ }
83
+ }
84
+ }
85
+
86
+ // Phase 2: Walk SerializedObject properties to find exact reference locations
87
+ foreach (var sourcePath in dependentPaths)
88
+ {
89
+ if (references.Count >= maxResults)
90
+ break;
91
+
92
+ var assets = AssetDatabase.LoadAllAssetsAtPath(sourcePath);
93
+ foreach (var asset in assets)
94
+ {
95
+ if (asset == null) continue;
96
+ if (references.Count >= maxResults) break;
97
+
98
+ var so = new SerializedObject(asset);
99
+ try
100
+ {
101
+ so.Update();
102
+ var iterator = so.GetIterator();
103
+ bool enterChildren = true;
104
+
105
+ while (iterator.Next(enterChildren))
106
+ {
107
+ enterChildren = true;
108
+
109
+ if (iterator.propertyType != SerializedPropertyType.ObjectReference)
110
+ continue;
111
+
112
+ var refValue = iterator.objectReferenceValue;
113
+ if (refValue == null) continue;
114
+
115
+ string refPath = AssetDatabase.GetAssetPath(refValue);
116
+ if (string.IsNullOrEmpty(refPath)) continue;
117
+
118
+ string refGuid = AssetDatabase.AssetPathToGUID(refPath);
119
+ if (refGuid != targetGuid) continue;
120
+
121
+ // If a specific fileId was requested, check it
122
+ if (targetFileId != 0)
123
+ {
124
+ if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(
125
+ refValue, out string _, out long localId))
126
+ continue;
127
+ if (localId != targetFileId)
128
+ continue;
129
+ }
130
+
131
+ references.Add(new Dictionary<string, object>
132
+ {
133
+ { "sourcePath", sourcePath },
134
+ { "sourceObjectName", asset.name },
135
+ { "sourceObjectType", asset.GetType().Name },
136
+ { "propertyPath", iterator.propertyPath },
137
+ { "targetGuid", targetGuid },
138
+ { "targetPath", targetPath },
139
+ { "referencedObjectName", refValue.name },
140
+ { "referencedObjectType", refValue.GetType().Name }
141
+ });
142
+
143
+ if (references.Count >= maxResults)
144
+ break;
145
+ }
146
+ }
147
+ finally
148
+ {
149
+ so.Dispose();
150
+ }
151
+ }
152
+ }
153
+
154
+ return new Dictionary<string, object>
155
+ {
156
+ { "targetGuid", targetGuid },
157
+ { "targetPath", targetPath ?? "" },
158
+ { "count", references.Count },
159
+ { "references", references }
160
+ };
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 1b9c94573b1b483180df2cfe7a7c6396
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
@@ -44,20 +44,26 @@ namespace UCP.Bridge
44
44
  throw new System.ArgumentException("Missing 'path' parameter");
45
45
 
46
46
  var path = pathObj.ToString();
47
+ var additive = GetBoolParam(p, "additive", false);
47
48
  var saveDirtyScenes = GetBoolParam(p, "saveDirtyScenes", true);
48
49
  var discardUntitled = GetBoolParam(p, "discardUntitled", true);
49
50
 
50
51
  if (EditorApplication.isPlaying)
51
52
  {
52
- SceneManager.LoadScene(path);
53
+ SceneManager.LoadScene(path, additive ? LoadSceneMode.Additive : LoadSceneMode.Single);
53
54
  }
54
55
  else
55
56
  {
56
57
  SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
57
- EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
58
+ EditorSceneManager.OpenScene(path, additive ? OpenSceneMode.Additive : OpenSceneMode.Single);
58
59
  }
59
60
 
60
- return new { status = "ok", loaded = path };
61
+ return new Dictionary<string, object>
62
+ {
63
+ ["status"] = "ok",
64
+ ["loaded"] = path,
65
+ ["additive"] = additive
66
+ };
61
67
  }
62
68
 
63
69
  private static bool GetBoolParam(Dictionary<string, object> parameters, string key, bool defaultValue)
@@ -11,6 +11,7 @@ namespace UCP.Bridge
11
11
  {
12
12
  private static TestRunnerApi s_api;
13
13
  private static TestResultCollector s_collector;
14
+ private const string ConsoleGuardName = "UCP.Bridge.Tests.ConsoleLogGuard";
14
15
 
15
16
  public static void Register(CommandRouter router)
16
17
  {
@@ -42,7 +43,8 @@ namespace UCP.Bridge
42
43
  if (s_collector != null)
43
44
  s_api.UnregisterCallbacks(s_collector);
44
45
 
45
- s_collector = new TestResultCollector();
46
+ var logCursor = LogsController.GetLatestId();
47
+ s_collector = new TestResultCollector(logCursor);
46
48
  s_api.RegisterCallbacks(s_collector);
47
49
 
48
50
  var executionSettings = new ExecutionSettings
@@ -130,11 +132,13 @@ namespace UCP.Bridge
130
132
  private class TestResultCollector : ICallbacks
131
133
  {
132
134
  private readonly List<object> _results = new();
135
+ private readonly long _logCursor;
133
136
  private int _passed, _failed, _skipped;
134
137
  private double _startTime;
135
138
 
136
- public TestResultCollector()
139
+ public TestResultCollector(long logCursor)
137
140
  {
141
+ _logCursor = logCursor;
138
142
  _startTime = EditorApplication.timeSinceStartup;
139
143
  }
140
144
 
@@ -147,6 +151,21 @@ namespace UCP.Bridge
147
151
  _failed = 0;
148
152
  _skipped = 0;
149
153
  CollectLeafResults(result);
154
+ var logSummary = LogsController.BuildStatusSummary(_logCursor);
155
+ var consoleWarnings = GetLevelCount(logSummary, "warning");
156
+ var consoleErrors = GetLevelCount(logSummary, "error") + GetLevelCount(logSummary, "exception");
157
+
158
+ if (consoleErrors > 0)
159
+ {
160
+ _failed++;
161
+ _results.Add(new Dictionary<string, object>
162
+ {
163
+ ["name"] = ConsoleGuardName,
164
+ ["status"] = "failed",
165
+ ["duration"] = 0.0,
166
+ ["message"] = BuildConsoleGuardMessage(consoleErrors, consoleWarnings, logSummary)
167
+ });
168
+ }
150
169
 
151
170
  var duration = EditorApplication.timeSinceStartup - _startTime;
152
171
 
@@ -156,13 +175,17 @@ namespace UCP.Bridge
156
175
  ["passed"] = _passed,
157
176
  ["failed"] = _failed,
158
177
  ["skipped"] = _skipped,
159
- ["duration"] = (double)duration
178
+ ["duration"] = (double)duration,
179
+ ["consoleClean"] = consoleErrors == 0,
180
+ ["consoleWarnings"] = consoleWarnings,
181
+ ["consoleErrors"] = consoleErrors
160
182
  };
161
183
 
162
184
  BridgeServer.BroadcastNotification("tests/result", new Dictionary<string, object>
163
185
  {
164
186
  ["summary"] = summary,
165
- ["tests"] = _results
187
+ ["tests"] = _results,
188
+ ["logs"] = logSummary
166
189
  });
167
190
 
168
191
  if (s_api != null)
@@ -235,6 +258,59 @@ namespace UCP.Bridge
235
258
 
236
259
  return result.Name;
237
260
  }
261
+
262
+ private static int GetLevelCount(Dictionary<string, object> logSummary, string level)
263
+ {
264
+ if (!logSummary.TryGetValue("byLevel", out var byLevelObj)
265
+ || byLevelObj is not Dictionary<string, object> byLevel
266
+ || !byLevel.TryGetValue(level, out var value))
267
+ {
268
+ return 0;
269
+ }
270
+
271
+ return Convert.ToInt32(value);
272
+ }
273
+
274
+ private static string BuildConsoleGuardMessage(
275
+ int consoleErrors,
276
+ int consoleWarnings,
277
+ Dictionary<string, object> logSummary)
278
+ {
279
+ var message = $"Unity emitted {consoleErrors} error/exception log(s) during the test run";
280
+ if (consoleWarnings > 0)
281
+ message += $" and {consoleWarnings} warning log(s)";
282
+
283
+ if (!logSummary.TryGetValue("topCategories", out var categoriesObj)
284
+ || categoriesObj is not List<object> categories
285
+ || categories.Count == 0)
286
+ {
287
+ return message + ".";
288
+ }
289
+
290
+ var parts = new List<string>();
291
+ foreach (var categoryObj in categories)
292
+ {
293
+ if (categoryObj is not Dictionary<string, object> category)
294
+ continue;
295
+
296
+ var count = category.TryGetValue("count", out var countObj)
297
+ ? Convert.ToInt32(countObj)
298
+ : 0;
299
+ var sample = category.TryGetValue("sampleMessage", out var sampleObj)
300
+ ? sampleObj?.ToString() ?? string.Empty
301
+ : string.Empty;
302
+ if (count <= 0 || string.IsNullOrEmpty(sample))
303
+ continue;
304
+
305
+ parts.Add($"{count}x {sample}");
306
+ if (parts.Count >= 3)
307
+ break;
308
+ }
309
+
310
+ return parts.Count == 0
311
+ ? message + "."
312
+ : $"{message}. Top categories: {string.Join(" | ", parts)}";
313
+ }
238
314
  }
239
315
  }
240
316
  }