@mflrevan/ucp 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Reflection;
4
+ using UnityEditor;
5
+ using UnityEngine;
6
+
7
+ namespace UCP.Bridge
8
+ {
9
+ public static class ShaderController
10
+ {
11
+ public static void Register(CommandRouter router)
12
+ {
13
+ router.Register("shader/errors", HandleErrors);
14
+ }
15
+
16
+ private static object HandleErrors(string paramsJson)
17
+ {
18
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
19
+ var errorsOnly = p != null && p.TryGetValue("errorsOnly", out var errorsOnlyObj) && errorsOnlyObj != null && Convert.ToBoolean(errorsOnlyObj);
20
+ var filter = p != null && p.TryGetValue("filter", out var filterObj) && filterObj != null ? filterObj.ToString() : null;
21
+
22
+ AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
23
+
24
+ var diagnostics = new List<object>();
25
+ var scanned = 0;
26
+ foreach (var guid in AssetDatabase.FindAssets("t:Shader"))
27
+ {
28
+ var path = AssetDatabase.GUIDToAssetPath(guid);
29
+ var shader = AssetDatabase.LoadAssetAtPath<Shader>(path);
30
+ if (shader == null)
31
+ continue;
32
+ if (!MatchesFilter(shader.name, path, filter))
33
+ continue;
34
+
35
+ scanned++;
36
+ foreach (var diagnostic in ReadShaderDiagnostics(shader, path, errorsOnly))
37
+ diagnostics.Add(diagnostic);
38
+ }
39
+
40
+ return new Dictionary<string, object>
41
+ {
42
+ ["status"] = "ok",
43
+ ["capability"] = FindShaderMessageMethod() != null ? "shader-messages" : "fallback",
44
+ ["scanned"] = scanned,
45
+ ["count"] = diagnostics.Count,
46
+ ["diagnostics"] = diagnostics
47
+ };
48
+ }
49
+
50
+ private static IEnumerable<object> ReadShaderDiagnostics(Shader shader, string path, bool errorsOnly)
51
+ {
52
+ var method = FindShaderMessageMethod();
53
+ if (method == null)
54
+ yield break;
55
+
56
+ var messages = method.Invoke(null, new object[] { shader }) as Array;
57
+ if (messages == null)
58
+ yield break;
59
+
60
+ foreach (var message in messages)
61
+ {
62
+ var isWarning = ReadBoolMember(message, "warning", "isWarning");
63
+ if (errorsOnly && isWarning)
64
+ continue;
65
+
66
+ yield return new Dictionary<string, object>
67
+ {
68
+ ["shader"] = shader.name,
69
+ ["path"] = path,
70
+ ["severity"] = isWarning ? "warning" : "error",
71
+ ["message"] = ReadStringMember(message, "message", "messageDetails"),
72
+ ["line"] = ReadIntMember(message, "line"),
73
+ ["platform"] = ReadStringMember(message, "platform"),
74
+ ["file"] = ReadStringMember(message, "file")
75
+ };
76
+ }
77
+ }
78
+
79
+ private static MethodInfo FindShaderMessageMethod()
80
+ {
81
+ var shaderUtil = typeof(Editor).Assembly.GetType("UnityEditor.ShaderUtil");
82
+ return FindShaderMethod(shaderUtil, "GetShaderMessages")
83
+ ?? FindShaderMethod(shaderUtil, "GetShaderErrors");
84
+ }
85
+
86
+ private static MethodInfo FindShaderMethod(Type shaderUtil, string name)
87
+ {
88
+ if (shaderUtil == null)
89
+ return null;
90
+
91
+ foreach (var method in shaderUtil.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
92
+ {
93
+ if (method.Name != name)
94
+ continue;
95
+ var parameters = method.GetParameters();
96
+ if (parameters.Length == 1 && parameters[0].ParameterType == typeof(Shader))
97
+ return method;
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ private static bool MatchesFilter(string shaderName, string path, string filter)
104
+ {
105
+ if (string.IsNullOrEmpty(filter))
106
+ return true;
107
+ return shaderName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0
108
+ || path.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
109
+ }
110
+
111
+ private static string ReadStringMember(object target, params string[] names)
112
+ {
113
+ foreach (var name in names)
114
+ {
115
+ var value = ReadMember(target, name);
116
+ if (value != null)
117
+ return value.ToString();
118
+ }
119
+
120
+ return string.Empty;
121
+ }
122
+
123
+ private static int ReadIntMember(object target, string name)
124
+ {
125
+ var value = ReadMember(target, name);
126
+ return value != null ? Convert.ToInt32(value) : 0;
127
+ }
128
+
129
+ private static bool ReadBoolMember(object target, params string[] names)
130
+ {
131
+ foreach (var name in names)
132
+ {
133
+ var value = ReadMember(target, name);
134
+ if (value != null)
135
+ return Convert.ToBoolean(value);
136
+ }
137
+
138
+ return false;
139
+ }
140
+
141
+ private static object ReadMember(object target, string name)
142
+ {
143
+ var type = target.GetType();
144
+ var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
145
+ if (field != null)
146
+ return field.GetValue(target);
147
+ var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
148
+ return property?.GetValue(target);
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 4cfbd1702c2741b48996b2de530bcb96
@@ -1,5 +1,6 @@
1
1
  using System;
2
2
  using System.Collections.Generic;
3
+ using System.Linq;
3
4
  using UnityEngine;
4
5
  using UnityEngine.SceneManagement;
5
6
 
@@ -10,6 +11,8 @@ namespace UCP.Bridge
10
11
  public static void Register(CommandRouter router)
11
12
  {
12
13
  router.Register("snapshot", HandleSnapshot);
14
+ router.Register("scene/query", HandleSceneQuery);
15
+ router.Register("object/get-children", HandleGetChildren);
13
16
  router.Register("objects/list", HandleListObjects);
14
17
  router.Register("objects/components", HandleGetComponents);
15
18
  router.Register("objects/transform", HandleGetTransform);
@@ -120,20 +123,84 @@ namespace UCP.Bridge
120
123
  return new Dictionary<string, object> { ["objects"] = objects };
121
124
  }
122
125
 
123
- private static void ListObjectsRecursive(GameObject go, List<object> list, int depth, int maxDepth)
126
+ private static object HandleSceneQuery(string paramsJson)
124
127
  {
125
- list.Add(new Dictionary<string, object>
128
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
129
+ if (p == null || !p.TryGetValue("query", out var queryObj) || queryObj == null)
130
+ throw new ArgumentException("Missing 'query' parameter");
131
+
132
+ var query = ParseSceneQuery(queryObj.ToString());
133
+ var fields = ParseFields(p.TryGetValue("fields", out var fieldsObj) ? fieldsObj?.ToString() : null);
134
+ var maxDepth = 32;
135
+ if (p.TryGetValue("depth", out var depthObj) && depthObj != null)
136
+ maxDepth = Math.Max(0, Convert.ToInt32(depthObj));
137
+
138
+ var scene = SceneManager.GetActiveScene();
139
+ var results = new List<object>();
140
+ foreach (var root in scene.GetRootGameObjects())
141
+ QueryHierarchy(root, query, fields, results, 0, maxDepth);
142
+
143
+ return new Dictionary<string, object>
126
144
  {
127
- ["instanceId"] = go.GetInstanceID(),
145
+ ["scene"] = scene.path,
146
+ ["sceneName"] = scene.name,
147
+ ["query"] = query.Raw,
148
+ ["count"] = results.Count,
149
+ ["objects"] = results
150
+ };
151
+ }
152
+
153
+ private static object HandleGetChildren(string paramsJson)
154
+ {
155
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
156
+ if (p == null || !p.TryGetValue("instanceId", out var idObj))
157
+ throw new ArgumentException("Missing 'instanceId' parameter");
158
+
159
+ int instanceId = Convert.ToInt32(idObj);
160
+ int maxDepth = 1;
161
+ if (p.TryGetValue("depth", out var depthObj))
162
+ maxDepth = Math.Max(1, Convert.ToInt32(depthObj));
163
+
164
+ var go = FindByInstanceId(instanceId);
165
+ if (go == null)
166
+ throw new Exception($"GameObject not found: {instanceId}");
167
+
168
+ var children = new List<object>();
169
+ int objectCount = 0;
170
+ int componentCount = 0;
171
+
172
+ for (int i = 0; i < go.transform.childCount; i++)
173
+ {
174
+ children.Add(SerializeGameObjectTree(
175
+ go.transform.GetChild(i).gameObject,
176
+ 1,
177
+ maxDepth,
178
+ ref objectCount,
179
+ ref componentCount));
180
+ }
181
+
182
+ return new Dictionary<string, object>
183
+ {
184
+ ["instanceId"] = instanceId,
128
185
  ["name"] = go.name,
129
186
  ["active"] = go.activeSelf,
130
187
  ["tag"] = go.tag,
131
188
  ["layer"] = go.layer,
132
189
  ["layerName"] = LayerMask.LayerToName(go.layer),
133
190
  ["childCount"] = go.transform.childCount,
134
- ["components"] = GetComponentTypes(go),
135
- ["depth"] = depth
136
- });
191
+ ["requestedDepth"] = maxDepth,
192
+ ["children"] = children,
193
+ ["stats"] = new Dictionary<string, object>
194
+ {
195
+ ["objectCount"] = objectCount,
196
+ ["componentCount"] = componentCount
197
+ }
198
+ };
199
+ }
200
+
201
+ private static void ListObjectsRecursive(GameObject go, List<object> list, int depth, int maxDepth)
202
+ {
203
+ list.Add(CreateGameObjectEntry(go, depth));
137
204
 
138
205
  if (depth < maxDepth)
139
206
  {
@@ -142,6 +209,140 @@ namespace UCP.Bridge
142
209
  }
143
210
  }
144
211
 
212
+ private static void QueryHierarchy(
213
+ GameObject go,
214
+ SceneQuery query,
215
+ HashSet<string> fields,
216
+ List<object> results,
217
+ int depth,
218
+ int maxDepth)
219
+ {
220
+ if (MatchesQuery(go, query))
221
+ results.Add(ProjectGameObject(go, fields, depth));
222
+
223
+ if (depth >= maxDepth)
224
+ return;
225
+
226
+ for (int i = 0; i < go.transform.childCount; i++)
227
+ QueryHierarchy(go.transform.GetChild(i).gameObject, query, fields, results, depth + 1, maxDepth);
228
+ }
229
+
230
+ private static Dictionary<string, object> ProjectGameObject(GameObject go, HashSet<string> fields, int depth)
231
+ {
232
+ var entry = new Dictionary<string, object>();
233
+ AddField(entry, fields, "instanceId", go.GetInstanceID());
234
+ AddField(entry, fields, "name", go.name);
235
+ AddField(entry, fields, "active", go.activeSelf);
236
+ AddField(entry, fields, "activeInHierarchy", go.activeInHierarchy);
237
+ AddField(entry, fields, "tag", go.tag);
238
+ AddField(entry, fields, "layer", go.layer);
239
+ AddField(entry, fields, "layerName", LayerMask.LayerToName(go.layer));
240
+ AddField(entry, fields, "depth", depth);
241
+ AddField(entry, fields, "childCount", go.transform.childCount);
242
+ AddField(entry, fields, "components", GetComponentTypes(go));
243
+ return entry;
244
+ }
245
+
246
+ private static void AddField(Dictionary<string, object> entry, HashSet<string> fields, string key, object value)
247
+ {
248
+ if (fields.Contains(key))
249
+ entry[key] = value;
250
+ }
251
+
252
+ private static HashSet<string> ParseFields(string raw)
253
+ {
254
+ var source = string.IsNullOrEmpty(raw) ? "instanceId,name,active,components" : raw;
255
+ return new HashSet<string>(
256
+ source.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
257
+ .Select(field => field.Trim()),
258
+ StringComparer.OrdinalIgnoreCase);
259
+ }
260
+
261
+ private static SceneQuery ParseSceneQuery(string raw)
262
+ {
263
+ var query = new SceneQuery { Raw = raw ?? string.Empty };
264
+ foreach (var token in SplitQuery(query.Raw))
265
+ {
266
+ var index = token.IndexOf('=');
267
+ if (index <= 0)
268
+ throw new ArgumentException($"Unsupported query token '{token}'. Use key=value.");
269
+
270
+ var key = token.Substring(0, index).Trim().ToLowerInvariant();
271
+ var value = token.Substring(index + 1).Trim().Trim('"');
272
+ switch (key)
273
+ {
274
+ case "name":
275
+ query.Name = value;
276
+ break;
277
+ case "component":
278
+ case "components":
279
+ query.Component = value;
280
+ break;
281
+ case "active":
282
+ query.Active = Convert.ToBoolean(value);
283
+ break;
284
+ case "tag":
285
+ query.Tag = value;
286
+ break;
287
+ case "layer":
288
+ query.Layer = value;
289
+ break;
290
+ default:
291
+ throw new ArgumentException($"Unsupported scene query key '{key}'");
292
+ }
293
+ }
294
+
295
+ return query;
296
+ }
297
+
298
+ private static IEnumerable<string> SplitQuery(string raw)
299
+ {
300
+ return (raw ?? string.Empty)
301
+ .Replace(" AND ", " and ")
302
+ .Split(new[] { " and ", "," }, StringSplitOptions.RemoveEmptyEntries)
303
+ .Select(token => token.Trim())
304
+ .Where(token => token.Length > 0);
305
+ }
306
+
307
+ private static bool MatchesQuery(GameObject go, SceneQuery query)
308
+ {
309
+ if (!string.IsNullOrEmpty(query.Name)
310
+ && !go.name.Contains(query.Name, StringComparison.OrdinalIgnoreCase))
311
+ return false;
312
+ if (!string.IsNullOrEmpty(query.Component) && !HasComponent(go, query.Component))
313
+ return false;
314
+ if (query.Active.HasValue && go.activeSelf != query.Active.Value)
315
+ return false;
316
+ if (!string.IsNullOrEmpty(query.Tag) && !string.Equals(go.tag, query.Tag, StringComparison.OrdinalIgnoreCase))
317
+ return false;
318
+ if (!string.IsNullOrEmpty(query.Layer) && !LayerMatches(go.layer, query.Layer))
319
+ return false;
320
+
321
+ return true;
322
+ }
323
+
324
+ private static bool HasComponent(GameObject go, string componentName)
325
+ {
326
+ foreach (var component in go.GetComponents<Component>())
327
+ {
328
+ if (component == null)
329
+ continue;
330
+ var type = component.GetType();
331
+ if (type.Name.Equals(componentName, StringComparison.OrdinalIgnoreCase)
332
+ || (type.FullName != null && type.FullName.Equals(componentName, StringComparison.OrdinalIgnoreCase)))
333
+ return true;
334
+ }
335
+
336
+ return false;
337
+ }
338
+
339
+ private static bool LayerMatches(int layer, string expected)
340
+ {
341
+ if (int.TryParse(expected, out var parsed))
342
+ return layer == parsed;
343
+ return string.Equals(LayerMask.LayerToName(layer), expected, StringComparison.OrdinalIgnoreCase);
344
+ }
345
+
145
346
  private static object HandleGetComponents(string paramsJson)
146
347
  {
147
348
  var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
@@ -202,8 +403,92 @@ namespace UCP.Bridge
202
403
 
203
404
  private static GameObject FindByInstanceId(int id)
204
405
  {
205
- var obj = UnityObjectCompat.ResolveByInstanceId(id);
206
- return obj as GameObject;
406
+ var direct = UnityObjectCompat.ResolveByInstanceId<GameObject>(id);
407
+ if (direct != null)
408
+ return direct;
409
+
410
+ for (int i = 0; i < SceneManager.sceneCount; i++)
411
+ {
412
+ var scene = SceneManager.GetSceneAt(i);
413
+ if (!scene.isLoaded)
414
+ continue;
415
+
416
+ foreach (var root in scene.GetRootGameObjects())
417
+ {
418
+ var found = FindInHierarchy(root, id);
419
+ if (found != null)
420
+ return found;
421
+ }
422
+ }
423
+
424
+ return null;
425
+ }
426
+
427
+ private static GameObject FindInHierarchy(GameObject go, int instanceId)
428
+ {
429
+ if (go.GetInstanceID() == instanceId)
430
+ return go;
431
+
432
+ for (int i = 0; i < go.transform.childCount; i++)
433
+ {
434
+ var found = FindInHierarchy(go.transform.GetChild(i).gameObject, instanceId);
435
+ if (found != null)
436
+ return found;
437
+ }
438
+
439
+ return null;
440
+ }
441
+
442
+ private static Dictionary<string, object> SerializeGameObjectTree(
443
+ GameObject go,
444
+ int depth,
445
+ int maxDepth,
446
+ ref int objectCount,
447
+ ref int componentCount)
448
+ {
449
+ objectCount++;
450
+
451
+ var entry = CreateGameObjectEntry(go, depth, ref componentCount);
452
+ if (depth < maxDepth)
453
+ {
454
+ var children = new List<object>();
455
+ for (int i = 0; i < go.transform.childCount; i++)
456
+ {
457
+ children.Add(SerializeGameObjectTree(
458
+ go.transform.GetChild(i).gameObject,
459
+ depth + 1,
460
+ maxDepth,
461
+ ref objectCount,
462
+ ref componentCount));
463
+ }
464
+
465
+ if (children.Count > 0)
466
+ entry["children"] = children;
467
+ }
468
+
469
+ return entry;
470
+ }
471
+
472
+ private static Dictionary<string, object> CreateGameObjectEntry(GameObject go, int depth)
473
+ {
474
+ int ignored = 0;
475
+ return CreateGameObjectEntry(go, depth, ref ignored);
476
+ }
477
+
478
+ private static Dictionary<string, object> CreateGameObjectEntry(GameObject go, int depth, ref int componentCount)
479
+ {
480
+ return new Dictionary<string, object>
481
+ {
482
+ ["instanceId"] = go.GetInstanceID(),
483
+ ["name"] = go.name,
484
+ ["active"] = go.activeSelf,
485
+ ["tag"] = go.tag,
486
+ ["layer"] = go.layer,
487
+ ["layerName"] = LayerMask.LayerToName(go.layer),
488
+ ["childCount"] = go.transform.childCount,
489
+ ["components"] = GetComponentTypes(go, ref componentCount),
490
+ ["depth"] = depth
491
+ };
207
492
  }
208
493
 
209
494
  private static List<object> GetComponentTypes(GameObject go)
@@ -223,5 +508,15 @@ namespace UCP.Bridge
223
508
  }
224
509
  return componentTypes;
225
510
  }
511
+
512
+ private sealed class SceneQuery
513
+ {
514
+ public string Raw;
515
+ public string Name;
516
+ public string Component;
517
+ public bool? Active;
518
+ public string Tag;
519
+ public string Layer;
520
+ }
226
521
  }
227
522
  }
@@ -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
  }