@principal-ai/principal-view-core 0.6.3 → 0.7.0

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.
Files changed (92) hide show
  1. package/dist/ConfigurationLoader.js +2 -1
  2. package/dist/ConfigurationLoader.js.map +1 -1
  3. package/dist/ConfigurationValidator.js.map +1 -1
  4. package/dist/EventProcessor.js.map +1 -1
  5. package/dist/EventRecorderService.js.map +1 -1
  6. package/dist/LibraryLoader.js.map +1 -1
  7. package/dist/PathBasedEventProcessor.js.map +1 -1
  8. package/dist/SessionManager.js +1 -1
  9. package/dist/SessionManager.js.map +1 -1
  10. package/dist/ValidationEngine.js.map +1 -1
  11. package/dist/cli/codegen.js.map +1 -1
  12. package/dist/codegen/type-generator.js.map +1 -1
  13. package/dist/codegen/usage-example.js.map +1 -1
  14. package/dist/helpers/GraphInstrumentationHelper.js +2 -2
  15. package/dist/helpers/GraphInstrumentationHelper.js.map +1 -1
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/narrative/example.d.ts +11 -0
  21. package/dist/narrative/example.d.ts.map +1 -0
  22. package/dist/narrative/example.js +331 -0
  23. package/dist/narrative/example.js.map +1 -0
  24. package/dist/narrative/index.d.ts +12 -0
  25. package/dist/narrative/index.d.ts.map +1 -0
  26. package/dist/narrative/index.js +14 -0
  27. package/dist/narrative/index.js.map +1 -0
  28. package/dist/narrative/scenario-matcher.d.ts +87 -0
  29. package/dist/narrative/scenario-matcher.d.ts.map +1 -0
  30. package/dist/narrative/scenario-matcher.js +269 -0
  31. package/dist/narrative/scenario-matcher.js.map +1 -0
  32. package/dist/narrative/template-parser.d.ts +33 -0
  33. package/dist/narrative/template-parser.d.ts.map +1 -0
  34. package/dist/narrative/template-parser.js +288 -0
  35. package/dist/narrative/template-parser.js.map +1 -0
  36. package/dist/narrative/template-renderer.d.ts +18 -0
  37. package/dist/narrative/template-renderer.d.ts.map +1 -0
  38. package/dist/narrative/template-renderer.js +367 -0
  39. package/dist/narrative/template-renderer.js.map +1 -0
  40. package/dist/narrative/types.d.ts +268 -0
  41. package/dist/narrative/types.d.ts.map +1 -0
  42. package/dist/narrative/types.js +10 -0
  43. package/dist/narrative/types.js.map +1 -0
  44. package/dist/rules/config.js.map +1 -1
  45. package/dist/rules/engine.js.map +1 -1
  46. package/dist/rules/implementations/connection-type-references.js.map +1 -1
  47. package/dist/rules/implementations/dead-end-states.js.map +1 -1
  48. package/dist/rules/implementations/library-node-type-match.js.map +1 -1
  49. package/dist/rules/implementations/minimum-node-sources.js.map +1 -1
  50. package/dist/rules/implementations/no-unknown-fields.js.map +1 -1
  51. package/dist/rules/implementations/orphaned-edge-types.js.map +1 -1
  52. package/dist/rules/implementations/orphaned-node-types.js.map +1 -1
  53. package/dist/rules/implementations/required-metadata.js.map +1 -1
  54. package/dist/rules/implementations/state-transition-references.js.map +1 -1
  55. package/dist/rules/implementations/unreachable-states.js.map +1 -1
  56. package/dist/rules/implementations/valid-action-patterns.js.map +1 -1
  57. package/dist/rules/implementations/valid-color-format.js.map +1 -1
  58. package/dist/rules/implementations/valid-edge-types.js.map +1 -1
  59. package/dist/rules/implementations/valid-node-types.js.map +1 -1
  60. package/dist/rules/types.js.map +1 -1
  61. package/dist/telemetry/coverage.js.map +1 -1
  62. package/dist/telemetry/event-validator.js.map +1 -1
  63. package/dist/types/audit.js.map +1 -1
  64. package/dist/types/canvas.js +5 -5
  65. package/dist/types/canvas.js.map +1 -1
  66. package/dist/types/otel.js.map +1 -1
  67. package/dist/types/resource-match.js.map +1 -1
  68. package/dist/utils/CanvasConverter.js.map +1 -1
  69. package/dist/utils/GraphConverter.js.map +1 -1
  70. package/dist/utils/LibraryConverter.js.map +1 -1
  71. package/dist/utils/PathMatcher.js.map +1 -1
  72. package/dist/utils/TraceToCanvas.js +7 -7
  73. package/dist/utils/TraceToCanvas.js.map +1 -1
  74. package/dist/utils/YamlParser.js.map +1 -1
  75. package/package.json +15 -15
  76. package/src/index.ts +31 -13
  77. package/src/narrative/README.md +381 -0
  78. package/src/narrative/__tests__/scenario-matcher.test.ts +368 -0
  79. package/src/narrative/__tests__/template-parser.test.ts +235 -0
  80. package/src/narrative/__tests__/template-renderer.test.ts +377 -0
  81. package/src/narrative/example.ts +349 -0
  82. package/src/narrative/index.ts +35 -0
  83. package/src/narrative/scenario-matcher.ts +331 -0
  84. package/src/narrative/template-parser.ts +298 -0
  85. package/src/narrative/template-renderer.ts +423 -0
  86. package/src/narrative/types.ts +368 -0
  87. package/src/utils/GraphConverter.test.ts +0 -79
  88. package/dist/utils/ExecutionFileDiscovery.d.ts +0 -206
  89. package/dist/utils/ExecutionFileDiscovery.d.ts.map +0 -1
  90. package/dist/utils/ExecutionFileDiscovery.js +0 -340
  91. package/dist/utils/ExecutionFileDiscovery.js.map +0 -1
  92. package/src/utils/ExecutionFileDiscovery.ts +0 -522
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Tests for scenario matching logic
3
+ */
4
+
5
+ import { selectScenario, matchesCondition, hasEventMatching, evaluateAssertion, computeAggregates, getNestedValue } from '../scenario-matcher';
6
+ import type { NarrativeTemplate, OtelEvent, ScenarioCondition } from '../types';
7
+
8
+ describe('hasEventMatching', () => {
9
+ const events: OtelEvent[] = [
10
+ { name: 'conversion.started', timestamp: 0 },
11
+ { name: 'conversion.complete', timestamp: 100 },
12
+ { name: 'rule.completed', timestamp: 50 },
13
+ { name: 'rule.error', timestamp: 75 },
14
+ { name: 'log.info', timestamp: 25, type: 'log', severityText: 'INFO' },
15
+ ];
16
+
17
+ it('should match exact event names', () => {
18
+ expect(hasEventMatching(events, 'conversion.started')).toBe(true);
19
+ expect(hasEventMatching(events, 'conversion.complete')).toBe(true);
20
+ expect(hasEventMatching(events, 'nonexistent')).toBe(false);
21
+ });
22
+
23
+ it('should match wildcard suffix patterns', () => {
24
+ expect(hasEventMatching(events, 'conversion.*')).toBe(true);
25
+ expect(hasEventMatching(events, 'rule.*')).toBe(true);
26
+ expect(hasEventMatching(events, 'test.*')).toBe(false);
27
+ });
28
+
29
+ it('should match wildcard prefix patterns', () => {
30
+ expect(hasEventMatching(events, '*.error')).toBe(true);
31
+ expect(hasEventMatching(events, '*.completed')).toBe(true);
32
+ expect(hasEventMatching(events, '*.failed')).toBe(false);
33
+ });
34
+
35
+ it('should match full wildcard', () => {
36
+ expect(hasEventMatching(events, '*')).toBe(true);
37
+ expect(hasEventMatching([], '*')).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('evaluateAssertion', () => {
42
+ it('should evaluate $gt (greater than)', () => {
43
+ expect(evaluateAssertion(10, { $gt: 5 }).matches).toBe(true);
44
+ expect(evaluateAssertion(5, { $gt: 5 }).matches).toBe(false);
45
+ expect(evaluateAssertion(3, { $gt: 5 }).matches).toBe(false);
46
+ });
47
+
48
+ it('should evaluate $gte (greater than or equal)', () => {
49
+ expect(evaluateAssertion(10, { $gte: 5 }).matches).toBe(true);
50
+ expect(evaluateAssertion(5, { $gte: 5 }).matches).toBe(true);
51
+ expect(evaluateAssertion(3, { $gte: 5 }).matches).toBe(false);
52
+ });
53
+
54
+ it('should evaluate $lt (less than)', () => {
55
+ expect(evaluateAssertion(3, { $lt: 5 }).matches).toBe(true);
56
+ expect(evaluateAssertion(5, { $lt: 5 }).matches).toBe(false);
57
+ expect(evaluateAssertion(10, { $lt: 5 }).matches).toBe(false);
58
+ });
59
+
60
+ it('should evaluate $lte (less than or equal)', () => {
61
+ expect(evaluateAssertion(3, { $lte: 5 }).matches).toBe(true);
62
+ expect(evaluateAssertion(5, { $lte: 5 }).matches).toBe(true);
63
+ expect(evaluateAssertion(10, { $lte: 5 }).matches).toBe(false);
64
+ });
65
+
66
+ it('should evaluate $eq (equality)', () => {
67
+ expect(evaluateAssertion(5, { $eq: 5 }).matches).toBe(true);
68
+ expect(evaluateAssertion('test', { $eq: 'test' }).matches).toBe(true);
69
+ expect(evaluateAssertion(true, { $eq: true }).matches).toBe(true);
70
+ expect(evaluateAssertion(5, { $eq: 10 }).matches).toBe(false);
71
+ });
72
+
73
+ it('should evaluate $ne (not equal)', () => {
74
+ expect(evaluateAssertion(5, { $ne: 10 }).matches).toBe(true);
75
+ expect(evaluateAssertion(5, { $ne: 5 }).matches).toBe(false);
76
+ });
77
+
78
+ it('should evaluate $exists', () => {
79
+ expect(evaluateAssertion('value', { $exists: true }).matches).toBe(true);
80
+ expect(evaluateAssertion(undefined, { $exists: true }).matches).toBe(false);
81
+ expect(evaluateAssertion(null, { $exists: true }).matches).toBe(false);
82
+ expect(evaluateAssertion(undefined, { $exists: false }).matches).toBe(true);
83
+ });
84
+
85
+ it('should evaluate $in (array membership)', () => {
86
+ expect(evaluateAssertion('a', { $in: ['a', 'b', 'c'] }).matches).toBe(true);
87
+ expect(evaluateAssertion(5, { $in: [1, 5, 10] }).matches).toBe(true);
88
+ expect(evaluateAssertion('d', { $in: ['a', 'b', 'c'] }).matches).toBe(false);
89
+ });
90
+
91
+ it('should evaluate $nin (not in array)', () => {
92
+ expect(evaluateAssertion('d', { $nin: ['a', 'b', 'c'] }).matches).toBe(true);
93
+ expect(evaluateAssertion('a', { $nin: ['a', 'b', 'c'] }).matches).toBe(false);
94
+ });
95
+
96
+ it('should handle undefined/null values for numeric comparisons', () => {
97
+ expect(evaluateAssertion(undefined, { $gt: 5 }).matches).toBe(false);
98
+ expect(evaluateAssertion(null, { $lt: 5 }).matches).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe('getNestedValue', () => {
103
+ const obj = {
104
+ result: {
105
+ violations: {
106
+ total: 10,
107
+ errors: 5,
108
+ },
109
+ },
110
+ simple: 'value',
111
+ };
112
+
113
+ it('should get simple property', () => {
114
+ expect(getNestedValue(obj, 'simple')).toBe('value');
115
+ });
116
+
117
+ it('should get nested property', () => {
118
+ expect(getNestedValue(obj, 'result.violations.total')).toBe(10);
119
+ expect(getNestedValue(obj, 'result.violations.errors')).toBe(5);
120
+ });
121
+
122
+ it('should return undefined for missing property', () => {
123
+ expect(getNestedValue(obj, 'nonexistent')).toBeUndefined();
124
+ expect(getNestedValue(obj, 'result.missing.path')).toBeUndefined();
125
+ });
126
+ });
127
+
128
+ describe('matchesCondition', () => {
129
+ const events: OtelEvent[] = [
130
+ { name: 'conversion.started', timestamp: 0 },
131
+ { name: 'conversion.complete', timestamp: 100 },
132
+ { name: 'rule.completed', timestamp: 50 },
133
+ ];
134
+
135
+ const attributes = {
136
+ 'result.violations.total': 5,
137
+ 'result.violations.errors': 2,
138
+ };
139
+
140
+ it('should match default condition', () => {
141
+ const condition: ScenarioCondition = { default: true };
142
+ expect(matchesCondition(condition, events, attributes).matches).toBe(true);
143
+ });
144
+
145
+ it('should match when all required events are present', () => {
146
+ const condition: ScenarioCondition = {
147
+ requires: ['conversion.started', 'conversion.complete'],
148
+ };
149
+ expect(matchesCondition(condition, events, attributes).matches).toBe(true);
150
+ });
151
+
152
+ it('should not match when required events are missing', () => {
153
+ const condition: ScenarioCondition = {
154
+ requires: ['conversion.started', 'missing.event'],
155
+ };
156
+ const result = matchesCondition(condition, events, attributes);
157
+ expect(result.matches).toBe(false);
158
+ expect(result.reason).toContain('missing.event');
159
+ });
160
+
161
+ it('should match with wildcard requires', () => {
162
+ const condition: ScenarioCondition = {
163
+ requires: ['conversion.*'],
164
+ };
165
+ expect(matchesCondition(condition, events, attributes).matches).toBe(true);
166
+ });
167
+
168
+ it('should not match when excluded events are present', () => {
169
+ const condition: ScenarioCondition = {
170
+ requires: ['conversion.*'],
171
+ excludes: ['rule.completed'],
172
+ };
173
+ const result = matchesCondition(condition, events, attributes);
174
+ expect(result.matches).toBe(false);
175
+ expect(result.reason).toContain('rule.completed');
176
+ });
177
+
178
+ it('should match when excluded events are not present', () => {
179
+ const condition: ScenarioCondition = {
180
+ requires: ['conversion.*'],
181
+ excludes: ['error.*'],
182
+ };
183
+ expect(matchesCondition(condition, events, attributes).matches).toBe(true);
184
+ });
185
+
186
+ it('should match with assertions', () => {
187
+ const condition: ScenarioCondition = {
188
+ requires: ['conversion.complete'],
189
+ assertions: {
190
+ 'result.violations.total': { $gt: 0 },
191
+ },
192
+ };
193
+ expect(matchesCondition(condition, events, attributes).matches).toBe(true);
194
+ });
195
+
196
+ it('should not match when assertions fail', () => {
197
+ const condition: ScenarioCondition = {
198
+ requires: ['conversion.complete'],
199
+ assertions: {
200
+ 'result.violations.total': { $eq: 0 },
201
+ },
202
+ };
203
+ const result = matchesCondition(condition, events, attributes);
204
+ expect(result.matches).toBe(false);
205
+ expect(result.reason).toContain('result.violations.total');
206
+ });
207
+
208
+ it('should match ANY when any=true', () => {
209
+ const condition: ScenarioCondition = {
210
+ requires: ['conversion.started', 'missing.event'],
211
+ any: true,
212
+ };
213
+ expect(matchesCondition(condition, events, attributes).matches).toBe(true);
214
+ });
215
+
216
+ it('should not match ANY when no requirements met', () => {
217
+ const condition: ScenarioCondition = {
218
+ requires: ['missing.one', 'missing.two'],
219
+ any: true,
220
+ };
221
+ const result = matchesCondition(condition, events, attributes);
222
+ expect(result.matches).toBe(false);
223
+ });
224
+ });
225
+
226
+ describe('selectScenario', () => {
227
+ const template: NarrativeTemplate = {
228
+ version: '1.0.0',
229
+ canvas: 'test.otel.canvas',
230
+ name: 'Test Template',
231
+ description: 'Test template for scenario selection',
232
+ mode: 'span-tree',
233
+ scenarioSelection: 'first-match',
234
+ scenarios: [
235
+ {
236
+ id: 'error',
237
+ priority: 1,
238
+ description: 'Error occurred',
239
+ condition: { requires: ['*.error'] },
240
+ template: { introduction: 'Error scenario' },
241
+ },
242
+ {
243
+ id: 'violations',
244
+ priority: 2,
245
+ description: 'Violations found',
246
+ condition: {
247
+ requires: ['conversion.complete'],
248
+ assertions: { 'result.violations.total': { $gt: 0 } },
249
+ },
250
+ template: { introduction: 'Violations scenario' },
251
+ },
252
+ {
253
+ id: 'happy',
254
+ priority: 3,
255
+ description: 'All good',
256
+ condition: {
257
+ requires: ['conversion.complete'],
258
+ assertions: { 'result.violations.total': { $eq: 0 } },
259
+ },
260
+ template: { introduction: 'Happy path' },
261
+ },
262
+ {
263
+ id: 'fallback',
264
+ priority: 99,
265
+ description: 'Fallback',
266
+ condition: { default: true },
267
+ template: { introduction: 'Fallback scenario' },
268
+ },
269
+ ],
270
+ };
271
+
272
+ it('should select first matching scenario (error)', () => {
273
+ const events: OtelEvent[] = [
274
+ { name: 'conversion.started', timestamp: 0 },
275
+ { name: 'conversion.error', timestamp: 50 },
276
+ ];
277
+ const result = selectScenario(template, events, {});
278
+ expect(result.scenario.id).toBe('error');
279
+ expect(result.isDefault).toBe(false);
280
+ });
281
+
282
+ it('should select violations scenario', () => {
283
+ const events: OtelEvent[] = [
284
+ { name: 'conversion.started', timestamp: 0 },
285
+ { name: 'conversion.complete', timestamp: 100 },
286
+ ];
287
+ const attributes = { 'result.violations.total': 5 };
288
+ const result = selectScenario(template, events, attributes);
289
+ expect(result.scenario.id).toBe('violations');
290
+ });
291
+
292
+ it('should select happy path scenario', () => {
293
+ const events: OtelEvent[] = [
294
+ { name: 'conversion.started', timestamp: 0 },
295
+ { name: 'conversion.complete', timestamp: 100 },
296
+ ];
297
+ const attributes = { 'result.violations.total': 0 };
298
+ const result = selectScenario(template, events, attributes);
299
+ expect(result.scenario.id).toBe('happy');
300
+ });
301
+
302
+ it('should select fallback when no other matches', () => {
303
+ const events: OtelEvent[] = [{ name: 'unknown.event', timestamp: 0 }];
304
+ const result = selectScenario(template, events, {});
305
+ expect(result.scenario.id).toBe('fallback');
306
+ expect(result.isDefault).toBe(true);
307
+ });
308
+
309
+ it('should throw error if no scenario matches (missing fallback)', () => {
310
+ const badTemplate: NarrativeTemplate = {
311
+ ...template,
312
+ scenarios: [template.scenarios[0]], // Only error scenario, no fallback
313
+ };
314
+ const events: OtelEvent[] = [{ name: 'conversion.complete', timestamp: 0 }];
315
+ expect(() => selectScenario(badTemplate, events, {})).toThrow('No scenario matched');
316
+ });
317
+
318
+ it('should return applicable scenarios for UI', () => {
319
+ const events: OtelEvent[] = [
320
+ { name: 'conversion.complete', timestamp: 100 },
321
+ ];
322
+ const attributes = { 'result.violations.total': 5 };
323
+ const result = selectScenario(template, events, attributes);
324
+ expect(result.applicableScenarios.length).toBeGreaterThan(1);
325
+ expect(result.applicableScenarios[0].id).toBe('violations');
326
+ expect(result.applicableScenarios).toContainEqual(expect.objectContaining({ id: 'fallback' }));
327
+ });
328
+ });
329
+
330
+ describe('computeAggregates', () => {
331
+ const events: OtelEvent[] = [
332
+ { name: 'conversion.started', timestamp: 0, type: 'span' },
333
+ { name: 'conversion.complete', timestamp: 100, type: 'span', attributes: { 'result.nodes.count': 12 } },
334
+ { name: 'log.info', timestamp: 25, type: 'log', severityNumber: 9 },
335
+ { name: 'log.error', timestamp: 75, type: 'log', severityNumber: 17 },
336
+ { name: 'log.debug', timestamp: 50, type: 'log', severityNumber: 5 },
337
+ ];
338
+
339
+ it('should count total events', () => {
340
+ const aggregates = computeAggregates(events);
341
+ expect(aggregates['events.count']).toBe(5);
342
+ expect(aggregates['events.length']).toBe(5);
343
+ });
344
+
345
+ it('should count spans', () => {
346
+ const aggregates = computeAggregates(events);
347
+ expect(aggregates['spans.count']).toBe(2);
348
+ });
349
+
350
+ it('should count logs by severity', () => {
351
+ const aggregates = computeAggregates(events);
352
+ expect(aggregates['logs.count']).toBe(3);
353
+ expect(aggregates['errorLogs.count']).toBe(1);
354
+ expect(aggregates['debugLogs.count']).toBe(1);
355
+ });
356
+
357
+ it('should extract common attributes', () => {
358
+ const aggregates = computeAggregates(events);
359
+ expect(aggregates['result.nodes.count']).toBe(12);
360
+ });
361
+
362
+ it('should handle empty events', () => {
363
+ const aggregates = computeAggregates([]);
364
+ expect(aggregates['events.count']).toBe(0);
365
+ expect(aggregates['spans.count']).toBe(0);
366
+ expect(aggregates['logs.count']).toBe(0);
367
+ });
368
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tests for template expression parser
3
+ */
4
+
5
+ import { parseTemplate, evaluateExpression } from '../template-parser';
6
+
7
+ describe('evaluateExpression', () => {
8
+ const context = {
9
+ config: { nodeTypes: 5, edgeTypes: 3 },
10
+ result: { nodes: { count: 12 }, violations: { total: 3, errors: 2 } },
11
+ duration: { ms: 45 },
12
+ count: 10,
13
+ text: 'hello',
14
+ };
15
+
16
+ describe('property access', () => {
17
+ it('should access simple properties', () => {
18
+ expect(evaluateExpression('count', context)).toBe(10);
19
+ expect(evaluateExpression('text', context)).toBe('hello');
20
+ });
21
+
22
+ it('should access nested properties', () => {
23
+ expect(evaluateExpression('config.nodeTypes', context)).toBe(5);
24
+ expect(evaluateExpression('result.nodes.count', context)).toBe(12);
25
+ expect(evaluateExpression('result.violations.total', context)).toBe(3);
26
+ });
27
+
28
+ it('should return undefined for missing properties', () => {
29
+ expect(evaluateExpression('missing', context)).toBeUndefined();
30
+ expect(evaluateExpression('config.missing', context)).toBeUndefined();
31
+ });
32
+ });
33
+
34
+ describe('literals', () => {
35
+ it('should handle string literals', () => {
36
+ expect(evaluateExpression("'hello'", context)).toBe('hello');
37
+ expect(evaluateExpression('"world"', context)).toBe('world');
38
+ });
39
+
40
+ it('should handle number literals', () => {
41
+ expect(evaluateExpression('42', context)).toBe(42);
42
+ expect(evaluateExpression('3.14', context)).toBe(3.14);
43
+ expect(evaluateExpression('-5', context)).toBe(-5);
44
+ });
45
+
46
+ it('should handle boolean literals', () => {
47
+ expect(evaluateExpression('true', context)).toBe(true);
48
+ expect(evaluateExpression('false', context)).toBe(false);
49
+ });
50
+
51
+ it('should handle null and undefined', () => {
52
+ expect(evaluateExpression('null', context)).toBe(null);
53
+ expect(evaluateExpression('undefined', context)).toBe(undefined);
54
+ });
55
+ });
56
+
57
+ describe('comparisons', () => {
58
+ it('should evaluate greater than', () => {
59
+ expect(evaluateExpression('count > 5', context)).toBe(true);
60
+ expect(evaluateExpression('count > 10', context)).toBe(false);
61
+ });
62
+
63
+ it('should evaluate greater than or equal', () => {
64
+ expect(evaluateExpression('count >= 10', context)).toBe(true);
65
+ expect(evaluateExpression('count >= 11', context)).toBe(false);
66
+ });
67
+
68
+ it('should evaluate less than', () => {
69
+ expect(evaluateExpression('count < 15', context)).toBe(true);
70
+ expect(evaluateExpression('count < 10', context)).toBe(false);
71
+ });
72
+
73
+ it('should evaluate less than or equal', () => {
74
+ expect(evaluateExpression('count <= 10', context)).toBe(true);
75
+ expect(evaluateExpression('count <= 9', context)).toBe(false);
76
+ });
77
+
78
+ it('should evaluate equality', () => {
79
+ expect(evaluateExpression('count === 10', context)).toBe(true);
80
+ expect(evaluateExpression('count === 5', context)).toBe(false);
81
+ expect(evaluateExpression("text === 'hello'", context)).toBe(true);
82
+ });
83
+
84
+ it('should evaluate inequality', () => {
85
+ expect(evaluateExpression('count !== 5', context)).toBe(true);
86
+ expect(evaluateExpression('count !== 10', context)).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe('arithmetic', () => {
91
+ it('should add', () => {
92
+ expect(evaluateExpression('count + 5', context)).toBe(15);
93
+ expect(evaluateExpression('config.nodeTypes + config.edgeTypes', context)).toBe(8);
94
+ });
95
+
96
+ it('should subtract', () => {
97
+ expect(evaluateExpression('count - 3', context)).toBe(7);
98
+ });
99
+
100
+ it('should multiply', () => {
101
+ expect(evaluateExpression('count * 2', context)).toBe(20);
102
+ });
103
+
104
+ it('should divide', () => {
105
+ expect(evaluateExpression('count / 2', context)).toBe(5);
106
+ expect(evaluateExpression('duration.ms / 1000', context)).toBe(0.045);
107
+ });
108
+ });
109
+
110
+ describe('ternary operator', () => {
111
+ it('should evaluate ternary with true condition', () => {
112
+ expect(evaluateExpression("count > 5 ? 'yes' : 'no'", context)).toBe('yes');
113
+ });
114
+
115
+ it('should evaluate ternary with false condition', () => {
116
+ expect(evaluateExpression("count < 5 ? 'yes' : 'no'", context)).toBe('no');
117
+ });
118
+
119
+ it('should handle nested ternaries', () => {
120
+ expect(evaluateExpression("count > 10 ? 'high' : count > 5 ? 'medium' : 'low'", context)).toBe('medium');
121
+ });
122
+ });
123
+
124
+ describe('string methods', () => {
125
+ it('should call repeat on string literal', () => {
126
+ expect(evaluateExpression("'━'.repeat(5)", context)).toBe('━━━━━');
127
+ expect(evaluateExpression("'ab'.repeat(3)", context)).toBe('ababab');
128
+ });
129
+
130
+ it('should call repeat on property value', () => {
131
+ const ctx = { separator: '=' };
132
+ expect(evaluateExpression("separator.repeat(10)", ctx)).toBe('==========');
133
+ });
134
+
135
+ it('should call substring', () => {
136
+ expect(evaluateExpression("'hello world'.substring(0, 5)", context)).toBe('hello');
137
+ });
138
+
139
+ it('should call slice', () => {
140
+ expect(evaluateExpression("'hello world'.slice(6)", context)).toBe('world');
141
+ });
142
+
143
+ it('should call toUpperCase', () => {
144
+ expect(evaluateExpression("'hello'.toUpperCase()", context)).toBe('HELLO');
145
+ });
146
+
147
+ it('should call toLowerCase', () => {
148
+ expect(evaluateExpression("'HELLO'.toLowerCase()", context)).toBe('hello');
149
+ });
150
+
151
+ it('should call trim', () => {
152
+ expect(evaluateExpression("' hello '.trim()", context)).toBe('hello');
153
+ });
154
+
155
+ it('should call replace', () => {
156
+ expect(evaluateExpression("'hello world'.replace('world', 'there')", context)).toBe('hello there');
157
+ });
158
+ });
159
+ });
160
+
161
+ describe('parseTemplate', () => {
162
+ const context = {
163
+ config: { nodeTypes: 5, edgeTypes: 3 },
164
+ result: { nodes: { count: 12 }, violations: { total: 3, errors: 2 } },
165
+ duration: { ms: 45 },
166
+ };
167
+
168
+ it('should parse simple property substitution', () => {
169
+ expect(parseTemplate('Found {result.nodes.count} nodes', context)).toBe('Found 12 nodes');
170
+ });
171
+
172
+ it('should parse multiple substitutions', () => {
173
+ expect(parseTemplate('Config has {config.nodeTypes} node types and {config.edgeTypes} edge types', context)).toBe(
174
+ 'Config has 5 node types and 3 edge types'
175
+ );
176
+ });
177
+
178
+ it('should parse nested properties', () => {
179
+ expect(parseTemplate('Total violations: {result.violations.total}', context)).toBe('Total violations: 3');
180
+ });
181
+
182
+ it('should parse expressions', () => {
183
+ expect(parseTemplate('Duration: {duration.ms}ms', context)).toBe('Duration: 45ms');
184
+ });
185
+
186
+ it('should parse ternary expressions', () => {
187
+ expect(parseTemplate('{result.violations.total > 0 ? "FAILED" : "PASSED"}', context)).toBe('FAILED');
188
+ });
189
+
190
+ it('should parse arithmetic', () => {
191
+ expect(parseTemplate('Total types: {config.nodeTypes + config.edgeTypes}', context)).toBe('Total types: 8');
192
+ });
193
+
194
+ it('should parse string method calls', () => {
195
+ expect(parseTemplate('{"=".repeat(10)}', context)).toBe('==========');
196
+ });
197
+
198
+ it('should handle multiple expressions in one template', () => {
199
+ const template = 'Status: {result.violations.total > 0 ? "❌" : "✅"} - {result.nodes.count} nodes';
200
+ expect(parseTemplate(template, context)).toBe('Status: ❌ - 12 nodes');
201
+ });
202
+
203
+ it('should leave non-expression text unchanged', () => {
204
+ expect(parseTemplate('This is plain text', context)).toBe('This is plain text');
205
+ expect(parseTemplate('No substitutions here!', context)).toBe('No substitutions here!');
206
+ });
207
+
208
+ it('should handle empty expressions gracefully', () => {
209
+ expect(parseTemplate('Value: {missing.property}', context)).toBe('Value: ');
210
+ });
211
+
212
+ it('should handle errors in expressions', () => {
213
+ expect(parseTemplate('{invalid.method.call()}', context)).toContain('ERROR');
214
+ });
215
+
216
+ it('should format complex narrative', () => {
217
+ const template = `✅ Conversion Complete
218
+ {'━'.repeat(50)}
219
+
220
+ Processed {config.nodeTypes} node types
221
+ Generated {result.nodes.count} nodes in {duration.ms}ms
222
+
223
+ Status: {result.violations.total === 0 ? "✅ SUCCESS" : "⚠️ {result.violations.total} violations"}`;
224
+
225
+ const expected = `✅ Conversion Complete
226
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
227
+
228
+ Processed 5 node types
229
+ Generated 12 nodes in 45ms
230
+
231
+ Status: ⚠️ 3 violations`;
232
+
233
+ expect(parseTemplate(template, context)).toBe(expected);
234
+ });
235
+ });