Blame Raw
· · · 3411 lines (172.2 KB)
0 contributors
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4test.describe('MacroEngine', () => {
5 test.beforeEach(testSetup.awaitST);
6
7 test.describe('Basic evaluation', () => {
8 test('should return input unchanged when there are no macros', async ({ page }) => {
9 const input = 'Hello world, no macros here.';
10 const output = await evaluateWithEngine(page, input);
11 expect(output).toBe(input);
12 });
13
14 test('should evaluate a simple macro without arguments', async ({ page }) => {
15 const input = 'Start {{newline}} end.';
16 const output = await evaluateWithEngine(page, input);
17 expect(output).toBe('Start \n end.');
18 });
19
20 test('should evaluate multiple macros in order', async ({ page }) => {
21 const input = 'A {{setvar::test::4}}{{getvar::test}} B {{setvar::test::2}}{{getvar::test}} C';
22 const output = await evaluateWithEngine(page, input);
23 expect(output).toBe('A 4 B 2 C');
24 });
25 });
26
27 test.describe('Unnamed arguments', () => {
28 test('should handle normal double-colon separated unnamed argument', async ({ page }) => {
29 const input = 'Reversed: {{reverse::abc}}!';
30 const output = await evaluateWithEngine(page, input);
31 expect(output).toBe('Reversed: cba!');
32 });
33
34 test('should handle (legacy) colon separated unnamed argument', async ({ page }) => {
35 const input = 'Reversed: {{reverse:abc}}!';
36 const output = await evaluateWithEngine(page, input);
37 expect(output).toBe('Reversed: cba!');
38 });
39
40 test('should handle (legacy) colon separated argument as only one, even with more separators (double colon)', async ({ page }) => {
41 const input = 'Reversed: {{reverse:abc::def}}!';
42 const output = await evaluateWithEngine(page, input);
43 expect(output).toBe('Reversed: fed::cba!');
44 });
45
46 test('should handle (legacy) colon separated argument as only one, even with more separators (single colon)', async ({ page }) => {
47 const input = 'Reversed: {{reverse:abc:def}}!';
48 const output = await evaluateWithEngine(page, input);
49 expect(output).toBe('Reversed: fed:cba!');
50 });
51
52 test('should handle (legacy) whitespace separated unnamed argument', async ({ page }) => {
53 const input = 'Values: {{roll 1d1}}!';
54 const output = await evaluateWithEngine(page, input);
55 expect(output).toBe('Values: 1!');
56 });
57
58 test('should handle (legacy) whitespace separated unnamed argument as only one, even with more separators (space)', async ({ page }) => {
59 const input = 'Values: {{reverse abc def}}!';
60 const output = await evaluateWithEngine(page, input);
61 expect(output).toBe('Values: fed cba!');
62 });
63
64 test('should support multi-line arguments for macros', async ({ page }) => {
65 const input = 'Result: {{reverse::first line\nsecond line}}'; // "\n" becomes a real newline in the macro argument
66 const output = await evaluateWithEngine(page, input);
67
68 const original = 'first line\nsecond line';
69 const expectedReversed = Array.from(original).reverse().join('');
70 expect(output).toBe(`Result: ${expectedReversed}`);
71 });
72 });
73
74 test.describe('Nested macros', () => {
75 test('should resolve nested macros inside arguments inside-out', async ({ page }) => {
76 const input = 'Result: {{setvar::test::0}}{{reverse::{{addvar::test::100}}{{getvar::test}}}}{{setvar::test::0}}';
77 const output = await evaluateWithEngine(page, input);
78 expect(output).toBe('Result: 001');
79 });
80
81 // {{wrap::{{upper::x}}::[::]}} -> '[X]'
82 test('should resolve nested macros across multiple arguments', async ({ page }) => {
83 const input = 'Result: {{setvar::addvname::test}}{{addvar::{{getvar::addvname}}::{{setvar::test::5}}{{getvar::test}}}}{{getvar::test}}';
84 const output = await evaluateWithEngine(page, input);
85 expect(output).toBe('Result: 10');
86 });
87 });
88
89 test.describe('Unknown macros', () => {
90 test('should keep unknown macro syntax but resolve nested macros inside it', async ({ page }) => {
91 const input = 'Test: {{unknown::{{newline}}}}';
92 const output = await evaluateWithEngine(page, input);
93 expect(output).toBe('Test: {{unknown::\n}}');
94 });
95
96 test('should keep surrounding text inside unknown macros intact', async ({ page }) => {
97 const input = 'Test: {{unknown::my {{newline}} example}}';
98 const output = await evaluateWithEngine(page, input);
99 expect(output).toBe('Test: {{unknown::my \n example}}');
100 });
101 });
102
103 test.describe('Comment macro', () => {
104 test('should remove single-line comments with simple body', async ({ page }) => {
105 const input = 'Hello{{// comment}}World';
106 const output = await evaluateWithEngine(page, input);
107 expect(output).toBe('HelloWorld');
108 });
109
110 test('should accept non-word characters immediately after //', async ({ page }) => {
111 const input = 'A{{//!@#$%^&*()_+}}B';
112 const output = await evaluateWithEngine(page, input);
113 expect(output).toBe('AB');
114 });
115
116 test('should ignore additional // sequences inside the comment body', async ({ page }) => {
117 const input = 'X{{//comment with // extra // slashes}}Y';
118 const output = await evaluateWithEngine(page, input);
119 expect(output).toBe('XY');
120 });
121
122 test('should support multi-line comment bodies', async ({ page }) => {
123 const input = 'Start{{// line one\nline two\nline three}}End';
124 const output = await evaluateWithEngine(page, input);
125 expect(output).toBe('StartEnd');
126 });
127 });
128
129 test.describe('Trim macro', () => {
130 test('should trim content inside scoped trim macro', async ({ page }) => {
131 const input = '{{trim}} hello world {{/trim}}';
132 const output = await evaluateWithEngine(page, input);
133 expect(output).toBe('hello world');
134 });
135
136 test('should trim leading whitespace in scoped trim', async ({ page }) => {
137 const input = '{{trim}}\n\n content{{/trim}}';
138 const output = await evaluateWithEngine(page, input);
139 expect(output).toBe('content');
140 });
141
142 test('should trim trailing whitespace in scoped trim', async ({ page }) => {
143 const input = '{{trim}}content \n\n{{/trim}}';
144 const output = await evaluateWithEngine(page, input);
145 expect(output).toBe('content');
146 });
147
148 test('should handle scoped trim with macros inside', async ({ page }) => {
149 const input = '{{trim}} Hello {{user}} {{/trim}}';
150 const output = await evaluateWithEngine(page, input);
151 expect(output).toBe('Hello User');
152 });
153
154 test('should handle nested scoped trim', async ({ page }) => {
155 const input = '{{trim}} outer {{trim}} inner {{/trim}} outer {{/trim}}';
156 const output = await evaluateWithEngine(page, input);
157 expect(output).toBe('outer inner outer');
158 });
159 });
160
161 test.describe('Legacy compatibility', () => {
162 test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
163 const input = 'foo\n\n{{trim}}\n\nbar';
164 const output = await evaluateWithEngine(page, input);
165 expect(output).toBe('foobar');
166 });
167
168 test('should handle multiple trim macros in a single string', async ({ page }) => {
169 const input = 'A\n\n{{trim}}\n\nB\n\n{{trim}}\n\nC';
170 const output = await evaluateWithEngine(page, input);
171 expect(output).toBe('ABC');
172 });
173
174 test('should support legacy time macro with positive offset via pre-processing', async ({ page }) => {
175 const input = 'Time: {{time_UTC+2}}';
176 const output = await evaluateWithEngine(page, input);
177
178 // After pre-processing, this should behave like {{time::UTC+2}} and be resolved by the time macro.
179 // We only assert that the placeholder was consumed and some non-empty value was produced.
180 expect(output).not.toBe(input);
181 expect(output.startsWith('Time: ')).toBeTruthy();
182 expect(output.length).toBeGreaterThan('Time: '.length);
183 });
184
185 test('should support legacy time macro with negative offset via pre-processing', async ({ page }) => {
186 const input = 'Time: {{time_UTC-10}}';
187 const output = await evaluateWithEngine(page, input);
188
189 expect(output).not.toBe(input);
190 expect(output.startsWith('Time: ')).toBeTruthy();
191 expect(output.length).toBeGreaterThan('Time: '.length);
192 });
193
194 test('should support legacy <USER> marker via pre-processing', async ({ page }) => {
195 const input = 'Hello <USER>!';
196 const output = await evaluateWithEngine(page, input);
197
198 // In the default test env, name1Override is "User".
199 expect(output).toBe('Hello User!');
200 });
201
202 test('should support legacy <BOT> and <CHAR> markers via pre-processing', async ({ page }) => {
203 const input = 'Bot: <BOT>, Char: <CHAR>.';
204 const output = await evaluateWithEngine(page, input);
205
206 // In the default test env, name2Override is "Character".
207 expect(output).toBe('Bot: Character, Char: Character.');
208 });
209
210 test('should support legacy <GROUP> and <CHARIFNOTGROUP> markers via pre-processing (non-group fallback)', async ({ page }) => {
211 const input = 'Group: <GROUP>, CharIfNotGroup: <CHARIFNOTGROUP>.';
212 const output = await evaluateWithEngine(page, input);
213
214 // Without an active group, both markers fall back to the current character name.
215 expect(output).toBe('Group: Character, CharIfNotGroup: Character.');
216 });
217 });
218
219 test.describe('Bracket handling around macros', () => {
220 test('should allow single opening brace inside macro arguments', async ({ page }) => {
221 const input = 'Test§ {{reverse::my { test}}';
222 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
223
224 // "my { test" reversed becomes "tset { ym"
225 expect(output).toBe('Test§ tset { ym');
226
227 const EXPECT_WARNINGS = false;
228 const EXPECT_ERRORS = false;
229 expect(hasMacroWarnings).toBe(EXPECT_WARNINGS);
230 expect(hasMacroErrors).toBe(EXPECT_ERRORS);
231 });
232
233 test('should allow single closing brace inside macro arguments', async ({ page }) => {
234 const input = 'Test§ {{reverse::my } test}}';
235 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
236
237 // "my } test" reversed becomes "tset } ym"
238 expect(output).toBe('Test§ tset } ym');
239
240 expect(hasMacroWarnings).toBe(false);
241 expect(hasMacroErrors).toBe(false);
242 });
243
244 test('should treat unterminated macro with identifier at end of input as plain text', async ({ page }) => {
245 const input = 'Test {{ hehe';
246 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
247
248 expect(output).toBe(input);
249
250 expect(hasMacroWarnings).toBe(true);
251 expect(hasMacroErrors).toBe(false);
252 });
253
254 test('should treat invalid macro start as plain text when followed by non-identifier characters', async ({ page }) => {
255 const input = 'Test {{§§ hehe';
256 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
257
258 expect(output).toBe(input);
259
260 expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
261 expect(hasMacroErrors).toBe(false);
262 });
263
264 test('should treat unterminated macro in the middle of the string as plain text', async ({ page }) => {
265 const input = 'Before {{ hehe After';
266 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
267
268 expect(output).toBe(input);
269
270 expect(hasMacroWarnings).toBe(true);
271 expect(hasMacroErrors).toBe(false);
272 });
273
274 test('should treat dangling macro start as text and still evaluate subsequent macro', async ({ page }) => {
275 const input = 'Test {{ hehe {{user}}';
276 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
277
278 // Default test env uses name1Override = "User" and name2Override = "Character".
279 expect(output).toBe('Test {{ hehe User');
280
281 expect(hasMacroWarnings).toBe(true);
282 expect(hasMacroErrors).toBe(false);
283 });
284
285 test('should ignore invalid macro start but still evaluate following valid macro', async ({ page }) => {
286 const input = 'Test {{&& hehe {{user}}';
287 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
288
289 // Default test env uses name1Override = "User" and name2Override = "Character".
290 expect(output).toBe('Test {{&& hehe User');
291
292 expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
293 expect(hasMacroErrors).toBe(false);
294 });
295
296 test('should allow single opening brace immediately before a macro', async ({ page }) => {
297 const input = '{{{char}}';
298 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
299
300 // One literal '{' plus the resolved character name.
301 expect(output).toBe('{Character');
302
303 expect(hasMacroWarnings).toBe(false);
304 expect(hasMacroErrors).toBe(false);
305 });
306
307 test('should allow single closing brace immediately after a macro', async ({ page }) => {
308 const input = '{{char}}}';
309 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
310
311 expect(output).toBe('Character}');
312
313 expect(hasMacroWarnings).toBe(false);
314 expect(hasMacroErrors).toBe(false);
315 });
316
317 test('should allow single braces around a macro', async ({ page }) => {
318 const input = '{{{char}}}';
319 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
320
321 expect(output).toBe('{Character}');
322
323 expect(hasMacroWarnings).toBe(false);
324 expect(hasMacroErrors).toBe(false);
325 });
326
327 test('should allow double opening braces immediately before a macro', async ({ page }) => {
328 const input = '{{{{char}}';
329 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
330
331 expect(output).toBe('{{Character');
332
333 expect(hasMacroWarnings).toBe(false);
334 expect(hasMacroErrors).toBe(false);
335 });
336
337 test('should allow double closing braces immediately after a macro', async ({ page }) => {
338 const input = '{{char}}}}';
339 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
340
341 expect(output).toBe('Character}}');
342
343 expect(hasMacroWarnings).toBe(false);
344 expect(hasMacroErrors).toBe(false);
345 });
346
347 test('should allow double braces around a macro', async ({ page }) => {
348 const input = '{{{{char}}}}';
349 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
350
351 expect(output).toBe('{{Character}}');
352
353 expect(hasMacroWarnings).toBe(false);
354 expect(hasMacroErrors).toBe(false);
355 });
356
357 test('should resolve nested macro inside argument with surrounding braces', async ({ page }) => {
358 const input = 'Result: {{reverse::pre-{ {{user}} }-post}}';
359 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
360
361 // Argument "pre-{ User }-post" reversed becomes "tsop-} resU {-erp".
362 expect(output).toBe('Result: tsop-} resU {-erp');
363
364 expect(hasMacroWarnings).toBe(false);
365 expect(hasMacroErrors).toBe(false);
366 });
367
368 test('should handle adjacent macros with no separator', async ({ page }) => {
369 const input = '{{char}}{{user}}';
370 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
371
372 expect(output).toBe('CharacterUser');
373
374 expect(hasMacroWarnings).toBe(false);
375 expect(hasMacroErrors).toBe(false);
376 });
377
378 test('should handle macros separated only by surrounding braces', async ({ page }) => {
379 const input = '{{char}}{ {{user}} }';
380 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
381
382 expect(output).toBe('Character{ User }');
383
384 expect(hasMacroWarnings).toBe(false);
385 expect(hasMacroErrors).toBe(false);
386 });
387
388 test('should handle Windows newlines with braces near macros', async ({ page }) => {
389 const input = 'Line1 {{char}}\r\n{Line2}';
390 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
391
392 expect(output).toBe('Line1 Character\r\n{Line2}');
393
394 expect(hasMacroWarnings).toBe(false);
395 expect(hasMacroErrors).toBe(false);
396 });
397
398 test('should treat stray closing braces outside macros as plain text', async ({ page }) => {
399 const input = 'Foo }} bar';
400 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
401
402 expect(output).toBe(input);
403
404 expect(hasMacroWarnings).toBe(false);
405 expect(hasMacroErrors).toBe(false);
406 });
407
408 test('should keep stray closing braces and still evaluate following macro', async ({ page }) => {
409 const input = 'Foo }} {{user}}';
410 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
411
412 expect(output).toBe('Foo }} User');
413
414 expect(hasMacroWarnings).toBe(false);
415 expect(hasMacroErrors).toBe(false);
416 });
417
418 test('should handle stray closing braces before macros as plain text', async ({ page }) => {
419 const input = 'Foo {{user}} }}';
420 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
421
422 expect(output).toBe('Foo User }}');
423
424 expect(hasMacroWarnings).toBe(false);
425 expect(hasMacroErrors).toBe(false);
426 });
427 });
428
429 test.describe('Arity errors', () => {
430 test('should not resolve macro without arguments when called with arguments', async ({ page }) => {
431 /** @type {string[]} */
432 const warnings = [];
433 page.on('console', msg => {
434 if (msg.type() === 'warning') {
435 warnings.push(msg.text());
436 }
437 });
438
439 const input = 'Start {{char::extra}} end.';
440 const output = await evaluateWithEngine(page, input);
441
442 // Macro text should remain unchanged
443 expect(output).toBe(input);
444
445 // Should have logged an arity warning for char
446 expect(warnings.some(w => w.includes('Macro "char"') && w.includes('unnamed arguments'))).toBeTruthy();
447 });
448
449 test('should not resolve reverse when called without arguments', async ({ page }) => {
450 /** @type {string[]} */
451 const warnings = [];
452 page.on('console', msg => {
453 if (msg.type() === 'warning') {
454 warnings.push(msg.text());
455 }
456 });
457
458 const input = 'Result: {{reverse}}';
459 const output = await evaluateWithEngine(page, input);
460
461 expect(output).toBe(input);
462
463 expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
464 });
465
466 test('should not resolve reverse when called with too many arguments', async ({ page }) => {
467 /** @type {string[]} */
468 const warnings = [];
469 page.on('console', msg => {
470 if (msg.type() === 'warning') {
471 warnings.push(msg.text());
472 }
473 });
474
475 const input = 'Result: {{reverse::a::b}}';
476 const output = await evaluateWithEngine(page, input);
477
478 // Macro text should remain unchanged when extra unnamed args are provided
479 expect(output).toBe(input);
480
481 // Should have logged an arity warning for reverse
482 expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
483 });
484
485 test('should not resolve list-bounded macro when called outside list bounds', async ({ page }) => {
486 /** @type {string[]} */
487 const warnings = [];
488 page.on('console', msg => {
489 if (msg.type() === 'warning') {
490 warnings.push(msg.text());
491 }
492 });
493
494 // Register a temporary macro with explicit list bounds: exactly 1 required + 1-2 list args
495 await page.evaluate(async () => {
496 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
497 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
498
499 MacroRegistry.unregisterMacro('test-list-bounds');
500 MacroRegistry.registerMacro('test-list-bounds', {
501 unnamedArgs: 1,
502 list: { min: 1, max: 2 },
503 description: 'Test macro for list bounds.',
504 handler: ({ unnamedArgs, list }) => {
505 const all = [...unnamedArgs, ...(list ?? [])];
506 return all.join('|');
507 },
508 });
509 });
510
511 // First macro: too few list args (only required arg)
512 // Second macro: too many list args (required arg + 3 list entries)
513 const input = 'A {{test-list-bounds::base}} B {{test-list-bounds::base::x::y::z}}';
514 const output = await evaluateWithEngine(page, input);
515
516 // Both macros should remain unchanged in the output
517 expect(output).toBe(input);
518
519 const testWarnings = warnings.filter(w => w.includes('Macro "test-list-bounds"') && w.includes('unnamed arguments'));
520 // We expect one warning for each invalid invocation (too few and too many list args)
521 expect(testWarnings.length).toBe(2);
522 });
523
524 test('should resolve nested macros in arguments, even though the outer macro has wrong number of arguments', async ({ page }) => {
525 // Macro {{user ....}} will fail, because it has no args, but {{char}} should still resolve
526 const input = 'Result: {{user Something {{char}}}}';
527 const output = await evaluateWithEngine(page, input);
528 expect(output).toBe('Result: {{user Something Character}}');
529 });
530
531 });
532
533 test.describe('Type validation', () => {
534 test('should not resolve strict typed macro when argument type is invalid', async ({ page }) => {
535 /** @type {string[]} */
536 const warnings = [];
537 page.on('console', msg => {
538 if (msg.type() === 'warning') {
539 warnings.push(msg.text());
540 }
541 });
542
543 await page.evaluate(async () => {
544 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
545 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
546
547 MacroRegistry.unregisterMacro('test-int-strict');
548 MacroRegistry.registerMacro('test-int-strict', {
549 unnamedArgs: [
550 { name: 'value', type: 'integer', description: 'Must be an integer.' },
551 ],
552 strictArgs: true,
553 description: 'Strict integer macro for testing type validation.',
554 handler: ({ unnamedArgs: [value] }) => `#${value}#`,
555 });
556 });
557
558 const input = 'Value: {{test-int-strict::abc}}';
559 const output = await evaluateWithEngine(page, input);
560
561 // Strict typed macro should leave the text unchanged when the argument is invalid
562 expect(output).toBe(input);
563
564 // A runtime type validation warning should be logged
565 expect(warnings.some(w => w.includes('Macro "test-int-strict"') && w.includes('expected type integer'))).toBeTruthy();
566 });
567
568 test('should resolve non-strict typed macro when argument type is invalid but still log warning', async ({ page }) => {
569 /** @type {string[]} */
570 const warnings = [];
571 page.on('console', msg => {
572 if (msg.type() === 'warning') {
573 warnings.push(msg.text());
574 }
575 });
576
577 await page.evaluate(async () => {
578 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
579 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
580
581 MacroRegistry.unregisterMacro('test-int-nonstrict');
582 MacroRegistry.registerMacro('test-int-nonstrict', {
583 unnamedArgs: [
584 { name: 'value', type: 'integer', description: 'Must be an integer.' },
585 ],
586 strictArgs: false,
587 description: 'Non-strict integer macro for testing type validation.',
588 handler: ({ unnamedArgs: [value] }) => `#${value}#`,
589 });
590 });
591
592 const input = 'Value: {{test-int-nonstrict::abc}}';
593 const output = await evaluateWithEngine(page, input);
594
595 // Non-strict typed macro should still execute, even with invalid type
596 expect(output).toBe('Value: #abc#');
597
598 // A runtime type validation warning should still be logged
599 expect(warnings.some(w => w.includes('Macro "test-int-nonstrict"') && w.includes('expected type integer'))).toBeTruthy();
600 });
601 });
602
603 test.describe('Environment', () => {
604 test('should expose original content as env.content to macro handlers', async ({ page }) => {
605 const input = '{{env-content}}';
606 const originalContent = 'This is the full original input string.';
607
608 const output = await page.evaluate(async ({ input, originalContent }) => {
609 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
610 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
611 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
612 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
613 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
614 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
615
616 MacroRegistry.unregisterMacro('env-content');
617 MacroRegistry.registerMacro('env-content', {
618 description: 'Test macro that returns env.content.',
619 handler: ({ env }) => env.content,
620 });
621
622 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
623 const rawEnv = {
624 content: originalContent,
625 };
626 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
627
628 return MacroEngine.evaluate(input, env);
629 }, { input, originalContent });
630
631 expect(output).toBe(originalContent);
632 });
633 });
634
635 test.describe('Deterministic pick macro', () => {
636 /** Fixed chat ID hash used across all pick tests for deterministic behavior */
637 const TEST_CHAT_ID_HASH = 123456;
638
639 /**
640 * Registers a testable pick macro that returns the seed string instead of the picked value.
641 * This allows tests to verify that different macro positions produce different seeds.
642 *
643 * @param {import('@playwright/test').Page} page
644 */
645 async function registerTestablePick(page) {
646 await page.evaluate(async () => {
647 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
648 const { MacroRegistry, MacroCategory } = await import('./scripts/macros/engine/MacroRegistry.js');
649 /** @type {import('../../public/scripts/utils.js')} */
650 const { getStringHash } = await import('./scripts/utils.js');
651 /** @type {import('../../public/script.js')} */
652 const { chat_metadata } = await import('./script.js');
653 /** @type {import('../../public/lib.js')} */
654 const { seedrandom } = await import('./lib.js');
655
656 // Only register once
657 if (MacroRegistry.getMacro('testablePick')) return;
658
659 MacroRegistry.registerMacro('testablePick', {
660 category: MacroCategory.RANDOM,
661 list: true,
662 description: 'Test version of pick that returns the seed string for verification.',
663 handler: ({ list, globalOffset, env }) => {
664 const chatIdHash = chat_metadata.chat_id_hash ?? 0;
665 const rawContentHash = env.contentHash;
666 const offset = globalOffset;
667 const rerollSeed = chat_metadata.pick_reroll_seed || null;
668 const combinedSeedString = [chatIdHash, rawContentHash, offset, rerollSeed].filter(it => it !== null).join('-');
669 // Return both the seed and what would be picked for validation
670 const finalSeed = getStringHash(combinedSeedString);
671 const rng = seedrandom(String(finalSeed));
672 const randomIndex = Math.floor(rng() * list.length);
673 return `seed:${combinedSeedString}|pick:${list[randomIndex]}`;
674 },
675 });
676 });
677 }
678
679 test.beforeEach(async ({ page }) => {
680 // Set consistent chat ID hash for all tests
681 await page.evaluate(async (hash) => {
682 /** @type {import('../../public/script.js')} */
683 const { chat_metadata } = await import('./script.js');
684 chat_metadata.chat_id_hash = hash;
685 }, TEST_CHAT_ID_HASH);
686 });
687
688 test('should return stable results for the same chat and content', async ({ page }) => {
689 const input = 'Choices: {{pick::red::green::blue}}, {{pick::red::green::blue}}.';
690
691 const output1 = await evaluateWithEngine(page, input);
692 const output2 = await evaluateWithEngine(page, input);
693
694 // Deterministic: same chat and same content should yield identical output
695 expect(output1).toBe(output2);
696
697 // Sanity check: both picks should resolve to one of the provided options
698 const match = output1.match(/Choices: ([^,]+), ([^.]+)\./);
699 expect(match).not.toBeNull();
700 if (!match) return;
701
702 const first = match[1].trim();
703 const second = match[2].trim();
704 const options = ['red', 'green', 'blue'];
705
706 expect(options.includes(first)).toBeTruthy();
707 expect(options.includes(second)).toBeTruthy();
708 });
709
710 test('should use different seeds for identical picks at different positions', async ({ page }) => {
711 await registerTestablePick(page);
712
713 const output = await page.evaluate(async () => {
714 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
715 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
716 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
717 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
718
719 const input = '{{testablePick::A::B::C}}###{{testablePick::A::B::C}}';
720 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
721 return MacroEngine.evaluate(input, env);
722 });
723
724 const parts = output.split('###');
725 expect(parts.length).toBe(2);
726
727 // Extract seeds from both results
728 const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
729 const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
730
731 expect(seed1).toBeTruthy();
732 expect(seed2).toBeTruthy();
733 // Seeds must be different because the macros are at different positions
734 expect(seed1).not.toBe(seed2);
735
736 // Verify picked values are valid options
737 const pick1 = parts[0].match(/pick:(\w+)/)?.[1];
738 const pick2 = parts[1].match(/pick:(\w+)/)?.[1];
739 const options = ['A', 'B', 'C'];
740 expect(options.includes(pick1 ?? '')).toBeTruthy();
741 expect(options.includes(pick2 ?? '')).toBeTruthy();
742 });
743
744 test('should use different seeds for identical picks inside different scoped macros at the same offset', async ({ page }) => {
745 await registerTestablePick(page);
746
747 // Key regression test: picks inside scoped content must use global offsets
748 const output = await page.evaluate(async () => {
749 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
750 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
751 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
752 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
753
754 // Two identical pick macros inside different setvar scopes
755 // Before the fix, both would get startOffset=0 relative to their argument
756 // After the fix, they get different globalOffset values
757 const input = '{{setvar::first}}{{testablePick::A::B::C}}{{/setvar}}{{setvar::second}}{{testablePick::A::B::C}}{{/setvar}}{{.first}}###{{.second}}';
758 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
759 return MacroEngine.evaluate(input, env);
760 });
761
762 const parts = output.split('###');
763 expect(parts.length).toBe(2);
764
765 const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
766 const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
767
768 expect(seed1).toBeTruthy();
769 expect(seed2).toBeTruthy();
770 // Seeds must be different - this is the key assertion for the fix
771 expect(seed1).not.toBe(seed2);
772 });
773
774 test('should use different seeds for identical picks in inline arguments', async ({ page }) => {
775 await registerTestablePick(page);
776
777 const output = await page.evaluate(async () => {
778 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
779 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
780 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
781 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
782
783 // Two identical pick macros inside different setvar inline arguments
784 const input = '{{setvar::first::{{testablePick::A::B::C}}}}{{setvar::second::{{testablePick::A::B::C}}}}{{.first}}###{{.second}}';
785 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
786 return MacroEngine.evaluate(input, env);
787 });
788
789 const parts = output.split('###');
790 expect(parts.length).toBe(2);
791
792 const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
793 const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
794
795 expect(seed1).toBeTruthy();
796 expect(seed2).toBeTruthy();
797 // Seeds must be different due to different global offsets
798 expect(seed1).not.toBe(seed2);
799 });
800
801 test('should maintain stability across evaluations for picks in scoped content', async ({ page }) => {
802 // Picks inside scoped content should still be deterministic (same result each time)
803 const outputs = await page.evaluate(async () => {
804 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
805 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
806 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
807 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
808
809 const input = '{{setvar::val}}{{pick::X::Y::Z}}{{/setvar}}{{.val}}';
810 const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
811 const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
812 const result1 = MacroEngine.evaluate(input, env1);
813 const result2 = MacroEngine.evaluate(input, env2);
814 return [result1, result2];
815 });
816
817 // Same input should produce same output (deterministic)
818 expect(outputs[0]).toBe(outputs[1]);
819 // Should be one of the valid options
820 expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
821 });
822
823 test('should use different seeds for identical picks inside different if blocks (delayArgResolution)', async ({ page }) => {
824 await registerTestablePick(page);
825
826 // Key regression test: picks inside {{if}} blocks use resolve() which must preserve globalOffset
827 // This tests the fix for macros with delayArgResolution that call resolve() internally
828 const output = await page.evaluate(async () => {
829 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
830 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
831 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
832 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
833
834 // Two identical pick macros inside different if blocks
835 // Before the fix, both would get contextOffset=0 when resolve() was called
836 // After the fix, resolve() passes the caller's globalOffset as contextOffset
837 const input = '{{if true}}{{testablePick::A::B::C}}{{/if}}###{{if true}}{{testablePick::A::B::C}}{{/if}}';
838 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
839 return MacroEngine.evaluate(input, env);
840 });
841
842 const parts = output.split('###');
843 expect(parts.length).toBe(2);
844
845 const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
846 const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
847
848 expect(seed1).toBeTruthy();
849 expect(seed2).toBeTruthy();
850 // Seeds must be different because the {{if}} blocks are at different positions
851 expect(seed1).not.toBe(seed2);
852 });
853
854 test('should maintain stability for picks inside if blocks across evaluations', async ({ page }) => {
855 // Picks inside if blocks should still be deterministic
856 const outputs = await page.evaluate(async () => {
857 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
858 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
859 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
860 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
861
862 const input = '{{if true}}{{pick::X::Y::Z}}{{/if}}';
863 const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
864 const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
865 const result1 = MacroEngine.evaluate(input, env1);
866 const result2 = MacroEngine.evaluate(input, env2);
867 return [result1, result2];
868 });
869
870 // Same input should produce same output (deterministic)
871 expect(outputs[0]).toBe(outputs[1]);
872 // Should be one of the valid options
873 expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
874 });
875 });
876
877 test.describe('Dynamic macros', () => {
878 test.describe('String value dynamic macros', () => {
879 test('should resolve dynamic macro with string value', async ({ page }) => {
880 const output = await page.evaluate(async () => {
881 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
882 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
883 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
884 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
885
886 const rawEnv = {
887 content: 'Test: {{myvalue}}',
888 dynamicMacros: {
889 myvalue: 'hello world',
890 },
891 };
892 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
893 return MacroEngine.evaluate('Test: {{myvalue}}', env);
894 });
895
896 expect(output).toBe('Test: hello world');
897 });
898
899 test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
900 const output = await page.evaluate(async () => {
901 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
902 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
903 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
904 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
905
906 const rawEnv = {
907 content: '',
908 dynamicMacros: {
909 num: 42,
910 },
911 };
912 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
913 return MacroEngine.evaluate('Value: {{num}}', env);
914 });
915
916 expect(output).toBe('Value: 42');
917 });
918
919 test('should not resolve string dynamic macro when called with arguments', async ({ page }) => {
920 const warnings = [];
921 page.on('console', msg => {
922 if (msg.type() === 'warning') warnings.push(msg.text());
923 });
924
925 const input = 'Dyn: {{myvalue::extra}}';
926 const output = await page.evaluate(async (input) => {
927 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
928 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
929 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
930 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
931
932 const rawEnv = {
933 content: input,
934 dynamicMacros: { myvalue: 'hello' },
935 };
936 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
937 return MacroEngine.evaluate(input, env);
938 }, input);
939
940 expect(output).toBe(input);
941 expect(warnings.some(w => w.includes('Macro "myvalue"') && w.includes('unnamed arguments'))).toBeTruthy();
942 });
943 });
944
945 test.describe('Handler function dynamic macros', () => {
946 test('should resolve dynamic macro with handler function', async ({ page }) => {
947 const output = await page.evaluate(async () => {
948 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
949 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
950 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
951 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
952
953 const rawEnv = {
954 content: '',
955 dynamicMacros: {
956 dyn: () => 'handler result',
957 },
958 };
959 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
960 return MacroEngine.evaluate('Result: {{dyn}}', env);
961 });
962
963 expect(output).toBe('Result: handler result');
964 });
965
966 test('should pass execution context to handler function', async ({ page }) => {
967 const output = await page.evaluate(async () => {
968 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
969 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
970 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
971 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
972
973 const rawEnv = {
974 content: 'full content here',
975 dynamicMacros: {
976 dyn: (ctx) => `name=${ctx.name}, content=${ctx.env.content}`,
977 },
978 };
979 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
980 return MacroEngine.evaluate('{{dyn}}', env);
981 });
982
983 expect(output).toBe('name=dyn, content=full content here');
984 });
985
986 test('should not resolve handler dynamic macro when called with arguments due to strict arity', async ({ page }) => {
987 const warnings = [];
988 page.on('console', msg => {
989 if (msg.type() === 'warning') warnings.push(msg.text());
990 });
991
992 const input = 'Dyn: {{dyn::extra}}';
993 const output = await page.evaluate(async (input) => {
994 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
995 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
996 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
997 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
998
999 const rawEnv = {
1000 content: input,
1001 dynamicMacros: {
1002 dyn: () => 'OK',
1003 },
1004 };
1005 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1006 return MacroEngine.evaluate(input, env);
1007 }, input);
1008
1009 expect(output).toBe(input);
1010 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
1011 });
1012 });
1013
1014 test.describe('MacroDefinitionOptions dynamic macros', () => {
1015 test('should resolve dynamic macro with MacroDefinitionOptions', async ({ page }) => {
1016 const output = await page.evaluate(async () => {
1017 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1018 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1019 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1020 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1021
1022 const rawEnv = {
1023 content: '',
1024 dynamicMacros: {
1025 greet: {
1026 description: 'A greeting macro',
1027 handler: () => 'Hello from options!',
1028 },
1029 },
1030 };
1031 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1032 return MacroEngine.evaluate('{{greet}}', env);
1033 });
1034
1035 expect(output).toBe('Hello from options!');
1036 });
1037
1038 test('should support unnamed arguments in dynamic macro with options', async ({ page }) => {
1039 const output = await page.evaluate(async () => {
1040 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1041 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1042 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1043 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1044
1045 const rawEnv = {
1046 content: '',
1047 dynamicMacros: {
1048 greet: {
1049 unnamedArgs: [{ name: 'name' }],
1050 handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1051 },
1052 },
1053 };
1054 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1055 return MacroEngine.evaluate('{{greet::World}}', env);
1056 });
1057
1058 expect(output).toBe('Hello, World!');
1059 });
1060
1061 test('should support multiple unnamed arguments in dynamic macro', async ({ page }) => {
1062 const output = await page.evaluate(async () => {
1063 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1064 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1065 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1066 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1067
1068 const rawEnv = {
1069 content: '',
1070 dynamicMacros: {
1071 wrap: {
1072 unnamedArgs: [
1073 { name: 'content' },
1074 { name: 'prefix' },
1075 { name: 'suffix' },
1076 ],
1077 handler: ({ unnamedArgs: [content, prefix, suffix] }) => `${prefix}${content}${suffix}`,
1078 },
1079 },
1080 };
1081 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1082 return MacroEngine.evaluate('{{wrap::hello::[::]}}', env);
1083 });
1084
1085 expect(output).toBe('[hello]');
1086 });
1087
1088 test('should support optional arguments in dynamic macro', async ({ page }) => {
1089 const output = await page.evaluate(async () => {
1090 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1091 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1092 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1093 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1094
1095 const rawEnv = {
1096 content: '',
1097 dynamicMacros: {
1098 greet: {
1099 unnamedArgs: [
1100 { name: 'name' },
1101 { name: 'greeting', optional: true, defaultValue: 'Hello' },
1102 ],
1103 handler: ({ unnamedArgs: [name, greeting] }) => `${greeting || 'Hello'}, ${name}!`,
1104 },
1105 },
1106 };
1107 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1108
1109 const result1 = MacroEngine.evaluate('{{greet::World}}', env);
1110 const result2 = MacroEngine.evaluate('{{greet::World::Hi}}', env);
1111 return { result1, result2 };
1112 });
1113
1114 expect(output.result1).toBe('Hello, World!');
1115 expect(output.result2).toBe('Hi, World!');
1116 });
1117
1118 test('should support list arguments in dynamic macro', async ({ page }) => {
1119 const output = await page.evaluate(async () => {
1120 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1121 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1122 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1123 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1124
1125 const rawEnv = {
1126 content: '',
1127 dynamicMacros: {
1128 join: {
1129 unnamedArgs: [{ name: 'separator' }],
1130 list: true,
1131 handler: ({ unnamedArgs: [sep], list }) => list.join(sep),
1132 },
1133 },
1134 };
1135 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1136 return MacroEngine.evaluate('{{join::-::a::b::c}}', env);
1137 });
1138
1139 expect(output).toBe('a-b-c');
1140 });
1141
1142 test('should enforce type validation in dynamic macro with options', async ({ page }) => {
1143 const warnings = [];
1144 page.on('console', msg => {
1145 if (msg.type() === 'warning') warnings.push(msg.text());
1146 });
1147
1148 const input = '{{calc::abc}}';
1149 const output = await page.evaluate(async (input) => {
1150 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1151 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1152 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1153 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1154
1155 const rawEnv = {
1156 content: input,
1157 dynamicMacros: {
1158 calc: {
1159 unnamedArgs: [{ name: 'value', type: 'integer' }],
1160 strictArgs: true,
1161 handler: ({ unnamedArgs: [val] }) => `#${val}#`,
1162 },
1163 },
1164 };
1165 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1166 return MacroEngine.evaluate(input, env);
1167 }, input);
1168
1169 expect(output).toBe(input);
1170 expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
1171 });
1172
1173 test('should respect strictArgs: false in dynamic macro with options', async ({ page }) => {
1174 const warnings = [];
1175 page.on('console', msg => {
1176 if (msg.type() === 'warning') warnings.push(msg.text());
1177 });
1178
1179 const output = await page.evaluate(async () => {
1180 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1181 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1182 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1183 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1184
1185 const rawEnv = {
1186 content: '',
1187 dynamicMacros: {
1188 calc: {
1189 unnamedArgs: [{ name: 'value', type: 'integer' }],
1190 strictArgs: false,
1191 handler: ({ unnamedArgs: [val] }) => `#${val}#`,
1192 },
1193 },
1194 };
1195 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1196 return MacroEngine.evaluate('{{calc::abc}}', env);
1197 });
1198
1199 expect(output).toBe('#abc#');
1200 expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
1201 });
1202
1203 test('should fail arity check in dynamic macro with options when too few args', async ({ page }) => {
1204 const warnings = [];
1205 page.on('console', msg => {
1206 if (msg.type() === 'warning') warnings.push(msg.text());
1207 });
1208
1209 const input = '{{greet}}';
1210 const output = await page.evaluate(async (input) => {
1211 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1212 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1213 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1214 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1215
1216 const rawEnv = {
1217 content: input,
1218 dynamicMacros: {
1219 greet: {
1220 unnamedArgs: [{ name: 'name' }],
1221 handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1222 },
1223 },
1224 };
1225 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1226 return MacroEngine.evaluate(input, env);
1227 }, input);
1228
1229 expect(output).toBe(input);
1230 expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
1231 });
1232
1233 test('should fail arity check in dynamic macro with options when too many args', async ({ page }) => {
1234 const warnings = [];
1235 page.on('console', msg => {
1236 if (msg.type() === 'warning') warnings.push(msg.text());
1237 });
1238
1239 const input = '{{greet::one::two}}';
1240 const output = await page.evaluate(async (input) => {
1241 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1242 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1243 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1244 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1245
1246 const rawEnv = {
1247 content: input,
1248 dynamicMacros: {
1249 greet: {
1250 unnamedArgs: [{ name: 'name' }],
1251 handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1252 },
1253 },
1254 };
1255 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1256 return MacroEngine.evaluate(input, env);
1257 }, input);
1258
1259 expect(output).toBe(input);
1260 expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
1261 });
1262
1263 test('should handle invalid MacroDefinitionOptions gracefully', async ({ page }) => {
1264 const warnings = [];
1265 page.on('console', msg => {
1266 if (msg.type() === 'warning') warnings.push(msg.text());
1267 });
1268
1269 const input = '{{bad}}';
1270 const output = await page.evaluate(async (input) => {
1271 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1272 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1273 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1274 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1275
1276 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
1277 const rawEnv = {
1278 content: input,
1279 dynamicMacros: {
1280 bad: {
1281 // Missing handler - should fail validation
1282 unnamedArgs: 1,
1283 },
1284 },
1285 };
1286 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1287 return MacroEngine.evaluate(input, env);
1288 }, input);
1289
1290 // Should remain unresolved since options are invalid
1291 expect(output).toBe(input);
1292 expect(warnings.some(w => w.includes('bad') && w.includes('is not defined correctly'))).toBeTruthy();
1293 });
1294 });
1295
1296 test.describe('Dynamic macro priority and case sensitivity', () => {
1297 test('should override registered macro with dynamic macro of same name', async ({ page }) => {
1298 const output = await page.evaluate(async () => {
1299 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1300 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1301 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1302 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1303
1304 const rawEnv = {
1305 content: '',
1306 name1Override: 'User',
1307 dynamicMacros: {
1308 user: 'DynamicUser',
1309 },
1310 };
1311 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1312 return MacroEngine.evaluate('{{user}}', env);
1313 });
1314
1315 expect(output).toBe('DynamicUser');
1316 });
1317
1318 test('should match dynamic macro names case-insensitively', async ({ page }) => {
1319 const output = await page.evaluate(async () => {
1320 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1321 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1322 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1323 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1324
1325 const rawEnv = {
1326 content: '',
1327 dynamicMacros: {
1328 MyMacro: 'value',
1329 },
1330 };
1331 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1332
1333 const r1 = MacroEngine.evaluate('{{MyMacro}}', env);
1334 const r2 = MacroEngine.evaluate('{{mymacro}}', env);
1335 const r3 = MacroEngine.evaluate('{{MYMACRO}}', env);
1336 return { r1, r2, r3 };
1337 });
1338
1339 expect(output.r1).toBe('value');
1340 expect(output.r2).toBe('value');
1341 expect(output.r3).toBe('value');
1342 });
1343
1344 test('should resolve multiple different dynamic macros in same evaluation', async ({ page }) => {
1345 const output = await page.evaluate(async () => {
1346 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1347 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1348 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1349 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1350
1351 const rawEnv = {
1352 content: '',
1353 dynamicMacros: {
1354 a: 'alpha',
1355 b: () => 'beta',
1356 c: {
1357 handler: () => 'gamma',
1358 },
1359 },
1360 };
1361 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1362 return MacroEngine.evaluate('{{a}}-{{b}}-{{c}}', env);
1363 });
1364
1365 expect(output).toBe('alpha-beta-gamma');
1366 });
1367 });
1368 });
1369
1370 test.describe('Macro flags', () => {
1371 test('should resolve macro with legacy hash flag (no effect)', async ({ page }) => {
1372 // Legacy hash flag should be parsed but have no effect
1373 const input = 'Hello {{#user}}!';
1374 const output = await evaluateWithEngine(page, input);
1375 expect(output).toBe('Hello User!');
1376 });
1377
1378 test('should keep unmatched closing block macro as raw text', async ({ page }) => {
1379 // Closing block without matching opening should be kept as raw
1380 const input = '{{/unknown}}';
1381 const output = await evaluateWithEngine(page, input);
1382 expect(output).toBe('{{/unknown}}');
1383 });
1384
1385 test('should keep unmatched closing block macro for existing macro as raw text', async ({ page }) => {
1386 // Closing block for a known macro (user) without matching opening should stay raw
1387 const input = '{{/user}}';
1388 const output = await evaluateWithEngine(page, input);
1389 expect(output).toBe('{{/user}}');
1390 });
1391
1392 test('should keep unmatched closing block macro with arguments as raw text', async ({ page }) => {
1393 // Closing block with arguments should stay raw (closing macros don't take args anyway)
1394 const input = '{{/getvar::test}}';
1395 const output = await evaluateWithEngine(page, input);
1396 expect(output).toBe('{{/getvar::test}}');
1397 });
1398
1399 test('should keep closing macro raw when surrounded by other content', async ({ page }) => {
1400 // Closing macro in middle of text should stay raw, other macros should resolve
1401 const input = 'Hello {{user}}, this {{/char}} is raw, bye {{char}}!';
1402 const output = await evaluateWithEngine(page, input);
1403 expect(output).toBe('Hello User, this {{/char}} is raw, bye Character!');
1404 });
1405
1406 test('should resolve scoped macro while keeping unrelated closing raw', async ({ page }) => {
1407 // Scoped macro resolves normally, unrelated closing stays raw
1408 const input = '{{setvar::x}}value{{/setvar}}{{/user}}{{getvar::x}}';
1409 const output = await evaluateWithEngine(page, input);
1410 expect(output).toBe('{{/user}}value');
1411 });
1412
1413 test('should pass flags to macro handler', async ({ page }) => {
1414 // Register a test macro that returns its flags
1415 const output = await page.evaluate(async () => {
1416 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1417 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1418 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1419 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1420 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1421 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1422
1423 MacroRegistry.unregisterMacro('test-flags');
1424 MacroRegistry.registerMacro('test-flags', {
1425 description: 'Test macro that returns its flags.',
1426 handler: ({ flags }) => {
1427 const activeFlags = flags.raw.join(',') || 'none';
1428 return `[${activeFlags}]`;
1429 },
1430 });
1431
1432 const rawEnv = { content: '' };
1433 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1434
1435 return MacroEngine.evaluate('{{test-flags}} / {{!test-flags}} / {{!?test-flags}}', env);
1436 });
1437
1438 expect(output).toBe('[none] / [!] / [!,?]');
1439 });
1440
1441 test('should correctly identify individual flags in handler', async ({ page }) => {
1442 const output = await page.evaluate(async () => {
1443 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1444 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1445 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1446 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1447 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1448 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1449
1450 MacroRegistry.unregisterMacro('test-flag-check');
1451 MacroRegistry.registerMacro('test-flag-check', {
1452 description: 'Test macro that checks specific flags.',
1453 handler: ({ flags }) => {
1454 const parts = [];
1455 if (flags.immediate) parts.push('immediate');
1456 if (flags.delayed) parts.push('delayed');
1457 if (flags.filter) parts.push('filter');
1458 if (flags.closingBlock) parts.push('closingBlock');
1459 if (flags.preserveWhitespace) parts.push('preserveWhitespace');
1460 return parts.join('+') || 'noflags';
1461 },
1462 });
1463
1464 const rawEnv = { content: '' };
1465 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1466
1467 const results = [
1468 MacroEngine.evaluate('{{test-flag-check}}', env),
1469 MacroEngine.evaluate('{{!test-flag-check}}', env),
1470 MacroEngine.evaluate('{{?test-flag-check}}', env),
1471 MacroEngine.evaluate('{{>test-flag-check}}', env),
1472 // Note: {{/test-flag-check}} would stay raw (unmatched closing macro)
1473 MacroEngine.evaluate('{{#test-flag-check}}', env),
1474 MacroEngine.evaluate('{{!?>test-flag-check}}', env),
1475 ];
1476 return results.join(' | ');
1477 });
1478
1479 // Closing flag (/) is not tested here as standalone closing macros stay raw
1480 expect(output).toBe('noflags | immediate | delayed | filter | preserveWhitespace | immediate+delayed+filter');
1481 });
1482
1483 test('should handle flags with arguments correctly', async ({ page }) => {
1484 const input = '{{!reverse::hello}}';
1485 const output = await evaluateWithEngine(page, input);
1486 // The flag should not affect the macro resolution
1487 expect(output).toBe('olleh');
1488 });
1489
1490 test('should handle multiple flags with whitespace', async ({ page }) => {
1491 const output = await page.evaluate(async () => {
1492 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1493 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1494 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1495 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1496 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1497 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1498
1499 MacroRegistry.unregisterMacro('test-flags-ws');
1500 MacroRegistry.registerMacro('test-flags-ws', {
1501 description: 'Test macro for flags with whitespace.',
1502 handler: ({ flags }) => flags.raw.length.toString(),
1503 });
1504
1505 const rawEnv = { content: '' };
1506 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1507
1508 return MacroEngine.evaluate('{{ ! ? > test-flags-ws }}', env);
1509 });
1510
1511 expect(output).toBe('3');
1512 });
1513 });
1514
1515 test.describe('Scoped macros', () => {
1516 test('should merge scoped content as last unnamed argument', async ({ page }) => {
1517 const input = '{{setvar::myvar}}Hello World{{/setvar}}{{getvar::myvar}}';
1518 const output = await evaluateWithEngine(page, input);
1519 expect(output).toBe('Hello World');
1520 });
1521
1522 test('should be equivalent to inline argument syntax', async ({ page }) => {
1523 const input1 = '{{setvar::myvar::test value}}{{getvar::myvar}}';
1524 const input2 = '{{setvar::myvar}}test value{{/setvar}}{{getvar::myvar}}';
1525
1526 const output1 = await evaluateWithEngine(page, input1);
1527 const output2 = await evaluateWithEngine(page, input2);
1528
1529 expect(output1).toBe(output2);
1530 });
1531
1532 test('should resolve nested macros inside scoped content', async ({ page }) => {
1533 const input = '{{setvar::myvar}}Hello {{user}}!{{/setvar}}{{getvar::myvar}}';
1534 const output = await evaluateWithEngine(page, input);
1535 expect(output).toBe('Hello User!');
1536 });
1537
1538 test('should handle nested scoped macros with same name', async ({ page }) => {
1539 // Outer scope sets 'outer', inner scope sets 'inner'
1540 // Since setvar returns '', the inner macro contributes nothing to outer's content
1541 const input = '{{setvar::outer}}before {{setvar::inner}}nested{{/setvar}} after{{/setvar}}{{getvar::outer}} | {{getvar::inner}}';
1542 const output = await evaluateWithEngine(page, input);
1543 expect(output).toBe('before after | nested'); // Note: double space where inner setvar was
1544 });
1545
1546 test('should handle multiple independent scoped macros', async ({ page }) => {
1547 const input = '{{setvar::a}}first{{/setvar}}{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
1548 const output = await evaluateWithEngine(page, input);
1549 expect(output).toBe('[first][second]');
1550 });
1551
1552 test('should keep unmatched closing tag as raw text', async ({ page }) => {
1553 const input = 'Before {{/setvar}} After';
1554 const output = await evaluateWithEngine(page, input);
1555 expect(output).toBe('Before {{/setvar}} After');
1556 });
1557
1558 test('should keep second closing tag as raw when already closed', async ({ page }) => {
1559 const input = '{{setvar::myvar}}content{{/setvar}}{{/setvar}}{{getvar::myvar}}';
1560 const output = await evaluateWithEngine(page, input);
1561 expect(output).toBe('{{/setvar}}content');
1562 });
1563
1564 test('should work with empty scoped content', async ({ page }) => {
1565 const input = '{{setvar::empty}}{{/setvar}}[{{getvar::empty}}]';
1566 const output = await evaluateWithEngine(page, input);
1567 expect(output).toBe('[]');
1568 });
1569
1570 test('should work with multi-line scoped content', async ({ page }) => {
1571 const input = '{{setvar::multi}}Line 1\nLine 2\nLine 3{{/setvar}}{{getvar::multi}}';
1572 const output = await evaluateWithEngine(page, input);
1573 expect(output).toBe('Line 1\nLine 2\nLine 3');
1574 });
1575
1576 test('should preserve plaintext around scoped macros', async ({ page }) => {
1577 const input = 'Before {{setvar::x}}value{{/setvar}} After {{getvar::x}}';
1578 const output = await evaluateWithEngine(page, input);
1579 expect(output).toBe('Before After value');
1580 });
1581
1582 test('should handle deeply nested scoped macros', async ({ page }) => {
1583 // Since setvar returns '', nested setvars contribute nothing to parent content
1584 // l3 = "C", l2 = "B" + "" + "B" = "BB", l1 = "A" + "" + "A" = "AA"
1585 const input = '{{setvar::l1}}A{{setvar::l2}}B{{setvar::l3}}C{{/setvar}}B{{/setvar}}A{{/setvar}}{{getvar::l1}}|{{getvar::l2}}|{{getvar::l3}}';
1586 const output = await evaluateWithEngine(page, input);
1587 expect(output).toBe('AA|BB|C');
1588 });
1589
1590 test('should handle scoped macro with existing arguments', async ({ page }) => {
1591 // reverse takes 1 arg; scoped content becomes the only arg
1592 const input = '{{reverse}}hello{{/reverse}}';
1593 const output = await evaluateWithEngine(page, input);
1594 expect(output).toBe('olleh');
1595 });
1596
1597 test('should not match closing tag for different macro name', async ({ page }) => {
1598 // Opening setvar, closing getvar - should not match
1599 const input = '{{setvar::x}}content{{/getvar}}{{getvar::x}}';
1600 const output = await evaluateWithEngine(page, input);
1601 // setvar without proper closing keeps looking, finds none, so it stays as is
1602 // getvar closing has no opener, stays as raw
1603 expect(output).toBe('{{setvar::x}}content{{/getvar}}');
1604 });
1605
1606 test('should handle scoped content with special characters', async ({ page }) => {
1607 const input = '{{setvar::special}}Hello { world } :: test{{/setvar}}{{getvar::special}}';
1608 const output = await evaluateWithEngine(page, input);
1609 expect(output).toBe('Hello { world } :: test');
1610 });
1611
1612 test('should set isScoped to true for scoped macro invocation', async ({ page }) => {
1613 const output = await page.evaluate(async () => {
1614 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1615 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1616 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1617 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1618 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1619 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1620
1621 MacroRegistry.unregisterMacro('test-isscoped');
1622 MacroRegistry.registerMacro('test-isscoped', {
1623 description: 'Test macro that reports isScoped value.',
1624 unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
1625 handler: ({ isScoped }) => `isScoped:${isScoped}`,
1626 });
1627
1628 const rawEnv = { content: '' };
1629 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1630 return MacroEngine.evaluate('{{test-isscoped}}content{{/test-isscoped}}', env);
1631 });
1632 expect(output).toBe('isScoped:true');
1633 });
1634
1635 test('should set isScoped to false for inline argument syntax', async ({ page }) => {
1636 const output = await page.evaluate(async () => {
1637 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1638 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1639 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1640 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1641 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1642 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1643
1644 MacroRegistry.unregisterMacro('test-isscoped');
1645 MacroRegistry.registerMacro('test-isscoped', {
1646 description: 'Test macro that reports isScoped value.',
1647 unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
1648 handler: ({ isScoped }) => `isScoped:${isScoped}`,
1649 });
1650
1651 const rawEnv = { content: '' };
1652 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1653 return MacroEngine.evaluate('{{test-isscoped::content}}', env);
1654 });
1655 expect(output).toBe('isScoped:false');
1656 });
1657
1658 test('should keep scoped macro raw when macro accepts no arguments', async ({ page }) => {
1659 // {{user}} takes no arguments, so {{user}}content{{/user}} should stay raw
1660 // But content inside should still resolve
1661 const input = '{{user}}Hello {{char}}!{{/user}}';
1662 const output = await evaluateWithEngine(page, input);
1663 expect(output).toBe('{{user}}Hello Character!{{/user}}');
1664 });
1665
1666 test('should keep scoped macro raw when argument count exceeds maximum', async ({ page }) => {
1667 // setvar takes 2 args (name, value). With scoped content as 3rd arg, it exceeds max.
1668 // When already at max args, scoped content would be extra - should stay raw
1669 const input = '{{setvar::myvar::existing}}extra{{/setvar}}{{getvar::myvar}}';
1670 const output = await evaluateWithEngine(page, input);
1671 expect(output).toBe('{{setvar::myvar::existing}}extra{{/setvar}}');
1672 });
1673
1674 test('should keep scoped macro raw when argument count is below minimum', async ({ page }) => {
1675 const output = await page.evaluate(async () => {
1676 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1677 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1678 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1679 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1680 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1681 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1682
1683 // Register a macro that requires exactly 3 arguments
1684 MacroRegistry.unregisterMacro('test-3args');
1685 MacroRegistry.registerMacro('test-3args', {
1686 description: 'Test macro requiring 3 arguments.',
1687 unnamedArgs: [
1688 { name: 'a', type: 'string', description: 'First' },
1689 { name: 'b', type: 'string', description: 'Second' },
1690 { name: 'c', type: 'string', description: 'Third' },
1691 ],
1692 handler: ({ unnamedArgs: [a, b, c] }) => `${a}-${b}-${c}`,
1693 });
1694
1695 const rawEnv = { content: '' };
1696 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1697 // Only 2 args (1 inline + 1 scoped), but needs 3 - should stay raw
1698 return MacroEngine.evaluate('{{test-3args::first}}second{{/test-3args}}', env);
1699 });
1700 expect(output).toBe('{{test-3args::first}}second{{/test-3args}}');
1701 });
1702
1703 test('should evaluate inner macros before outer macro in scoped content', async ({ page }) => {
1704 const output = await page.evaluate(async () => {
1705 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1706 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1707 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1708 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1709 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
1710 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
1711
1712 // Track evaluation order
1713 const evalOrder = [];
1714
1715 MacroRegistry.unregisterMacro('test-outer');
1716 MacroRegistry.registerMacro('test-outer', {
1717 description: 'Outer test macro.',
1718 unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
1719 handler: ({ unnamedArgs: [content] }) => {
1720 evalOrder.push('outer');
1721 return `[outer:${content}]`;
1722 },
1723 });
1724
1725 MacroRegistry.unregisterMacro('test-inner');
1726 MacroRegistry.registerMacro('test-inner', {
1727 description: 'Inner test macro.',
1728 handler: () => {
1729 evalOrder.push('inner');
1730 return 'INNER';
1731 },
1732 });
1733
1734 const rawEnv = { content: '' };
1735 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1736 const result = MacroEngine.evaluate('{{test-outer}}before {{test-inner}} after{{/test-outer}}', env);
1737 return { result, order: evalOrder.join(',') };
1738 });
1739 expect(output.result).toBe('[outer:before INNER after]');
1740 expect(output.order).toBe('inner,outer');
1741 });
1742
1743 test('should handle scoped macro inside another scoped macro content', async ({ page }) => {
1744 // Both scoped macros should resolve, inner first
1745 const input = '{{setvar::outer}}A{{setvar::inner}}B{{/setvar}}C{{/setvar}}{{getvar::outer}}|{{getvar::inner}}';
1746 const output = await evaluateWithEngine(page, input);
1747 // inner = "B", outer = "A" + "" + "C" = "AC" (setvar returns empty string)
1748 expect(output).toBe('AC|B');
1749 });
1750
1751 test('should auto-trim whitespace-only scoped content to empty', async ({ page }) => {
1752 const input = '{{setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
1753 const output = await evaluateWithEngine(page, input);
1754 expect(output).toBe('[]');
1755 });
1756
1757 test('should preserve whitespace-only scoped content with # flag', async ({ page }) => {
1758 const input = '{{#setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
1759 const output = await evaluateWithEngine(page, input);
1760 expect(output).toBe('[ ]');
1761 });
1762
1763 test('should handle scoped macro at start of input', async ({ page }) => {
1764 const input = '{{setvar::x}}value{{/setvar}}result:{{getvar::x}}';
1765 const output = await evaluateWithEngine(page, input);
1766 expect(output).toBe('result:value');
1767 });
1768
1769 test('should handle scoped macro at end of input', async ({ page }) => {
1770 const input = 'prefix {{setvar::x}}value{{/setvar}}';
1771 const output = await evaluateWithEngine(page, input);
1772 expect(output).toBe('prefix ');
1773 });
1774
1775 test('should handle consecutive scoped macros', async ({ page }) => {
1776 const input = '{{setvar::a}}1{{/setvar}}{{setvar::b}}2{{/setvar}}{{setvar::c}}3{{/setvar}}{{getvar::a}}{{getvar::b}}{{getvar::c}}';
1777 const output = await evaluateWithEngine(page, input);
1778 expect(output).toBe('123');
1779 });
1780
1781 test('should handle scoped macro with only macro content (no plaintext)', async ({ page }) => {
1782 const input = '{{setvar::x}}{{user}}{{/setvar}}{{getvar::x}}';
1783 const output = await evaluateWithEngine(page, input);
1784 expect(output).toBe('User');
1785 });
1786
1787 test('should not match closing tag across different macro instances', async ({ page }) => {
1788 // Two separate setvar macros - second closing should not match first opening
1789 const input = '{{setvar::a}}first{{/setvar}}middle{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
1790 const output = await evaluateWithEngine(page, input);
1791 expect(output).toBe('middle[first][second]');
1792 });
1793
1794 test.describe('scoped macros nested inside arguments', () => {
1795 test('should resolve scoped macro inside another macro argument', async ({ page }) => {
1796 // {{reverse}}hello{{/reverse}} inside setvar's value argument should resolve first
1797 const input = '{{setvar::testvar::{{reverse}}hello{{/reverse}}}} {{getvar::testvar}}';
1798 const output = await evaluateWithEngine(page, input);
1799 expect(output).toBe(' olleh');
1800 });
1801
1802 test('should resolve scoped if macro inside setvar argument', async ({ page }) => {
1803 // {{if true}}true branch{{/if}} inside setvar should resolve to "true branch"
1804 const input = '{{setvar::testvar::{{if true}}true branch{{/if}}}} {{getvar::testvar}}';
1805 const output = await evaluateWithEngine(page, input);
1806 expect(output).toBe(' true branch');
1807 });
1808
1809 test('should resolve scoped if/else macro inside setvar argument', async ({ page }) => {
1810 const input = '{{setvar::testvar::{{if 0}}wrong{{else}}correct{{/if}}}} {{getvar::testvar}}';
1811 const output = await evaluateWithEngine(page, input);
1812 expect(output).toBe(' correct');
1813 });
1814
1815 test('should resolve multiple scoped macros inside single argument', async ({ page }) => {
1816 // Two scoped macros in the same argument
1817 const input = '{{setvar::testvar::{{reverse}}ab{{/reverse}}-{{reverse}}cd{{/reverse}}}} {{getvar::testvar}}';
1818 const output = await evaluateWithEngine(page, input);
1819 expect(output).toBe(' ba-dc');
1820 });
1821
1822 test('should resolve deeply nested scoped macros in arguments', async ({ page }) => {
1823 // Scoped macro inside scoped macro inside argument
1824 const input = '{{setvar::outer::{{setvar::inner::{{reverse}}xyz{{/reverse}}}}{{getvar::inner}}}} {{getvar::outer}}';
1825 const output = await evaluateWithEngine(page, input);
1826 expect(output).toBe(' zyx');
1827 });
1828
1829 test('should resolve scoped macro with text before and after in argument', async ({ page }) => {
1830 const input = '{{setvar::testvar::before {{reverse}}mid{{/reverse}} after}} {{getvar::testvar}}';
1831 const output = await evaluateWithEngine(page, input);
1832 expect(output).toBe(' before dim after');
1833 });
1834
1835 test('should handle scoped macro inside first argument when macro has multiple args', async ({ page }) => {
1836 // setvar has two args: name and value. Test scoped in value position.
1837 const input = '{{setvar::myvar::prefix-{{reverse}}abc{{/reverse}}-suffix}}{{getvar::myvar}}';
1838 const output = await evaluateWithEngine(page, input);
1839 expect(output).toBe('prefix-cba-suffix');
1840 });
1841
1842 test('should handle multiline scoped content inside argument', async ({ page }) => {
1843 const input = '{{setvar::testvar::{{if true}}\ntrue\nbranch\n{{/if}}}} {{getvar::testvar}}';
1844 const output = await evaluateWithEngine(page, input);
1845 expect(output).toBe(' true\nbranch');
1846 });
1847 });
1848 });
1849
1850 test.describe('{{if}} conditional macro', () => {
1851 test.describe('with literal values', () => {
1852 test('should return content when condition is truthy string', async ({ page }) => {
1853 const input = '{{if::hello::shown}}';
1854 const output = await evaluateWithEngine(page, input);
1855 expect(output).toBe('shown');
1856 });
1857
1858 test('should return empty when condition is empty string', async ({ page }) => {
1859 const input = '{{if::::hidden}}';
1860 const output = await evaluateWithEngine(page, input);
1861 expect(output).toBe('');
1862 });
1863
1864 test('should return empty when condition is "false"', async ({ page }) => {
1865 const input = '{{if::false::hidden}}';
1866 const output = await evaluateWithEngine(page, input);
1867 expect(output).toBe('');
1868 });
1869
1870 test('should return empty when condition is "off"', async ({ page }) => {
1871 const input = '{{if::off::hidden}}';
1872 const output = await evaluateWithEngine(page, input);
1873 expect(output).toBe('');
1874 });
1875
1876 test('should return empty when condition is "0"', async ({ page }) => {
1877 const input = '{{if::0::hidden}}';
1878 const output = await evaluateWithEngine(page, input);
1879 expect(output).toBe('');
1880 });
1881
1882 test('should return content when condition is "true"', async ({ page }) => {
1883 const input = '{{if::true::shown}}';
1884 const output = await evaluateWithEngine(page, input);
1885 expect(output).toBe('shown');
1886 });
1887
1888 test('should return content when condition is "1"', async ({ page }) => {
1889 const input = '{{if::1::shown}}';
1890 const output = await evaluateWithEngine(page, input);
1891 expect(output).toBe('shown');
1892 });
1893 });
1894
1895 test.describe('with macro name resolution', () => {
1896 test('should resolve macro name and return content when macro returns truthy', async ({ page }) => {
1897 // {{char}} returns "Character" (set in test env)
1898 const input = '{{if char}}Name: {{char}}{{/if}}';
1899 const output = await evaluateWithEngine(page, input);
1900 expect(output).toBe('Name: Character');
1901 });
1902
1903 test('should resolve macro name and return empty when macro returns empty', async ({ page }) => {
1904 // {{noop}} is a registered macro that always returns empty string
1905 const input = '{{if noop}}should not show{{/if}}[end]';
1906 const output = await evaluateWithEngine(page, input);
1907 expect(output).toBe('[end]');
1908 });
1909
1910 test('should not resolve non-existent macro names (treat as literal)', async ({ page }) => {
1911 // "notamacro" is not registered, so it's truthy as a literal string
1912 const input = '{{if::notamacro::shown}}';
1913 const output = await evaluateWithEngine(page, input);
1914 expect(output).toBe('shown');
1915 });
1916
1917 test('should resolve user macro and show content', async ({ page }) => {
1918 // {{user}} returns "User" (set in test env)
1919 const input = '{{if user}}Hello {{user}}{{/if}}';
1920 const output = await evaluateWithEngine(page, input);
1921 expect(output).toBe('Hello User');
1922 });
1923 });
1924
1925 test.describe('with nested macros in condition', () => {
1926 test('should evaluate nested macro in condition (truthy)', async ({ page }) => {
1927 const input = '{{setvar::flag::yes}}{{if {{getvar::flag}}}}shown{{/if}}';
1928 const output = await evaluateWithEngine(page, input);
1929 expect(output).toBe('shown');
1930 });
1931
1932 test('should evaluate nested macro in condition (falsy)', async ({ page }) => {
1933 const input = '{{setvar::flag::}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
1934 const output = await evaluateWithEngine(page, input);
1935 expect(output).toBe('[end]');
1936 });
1937
1938 test('should evaluate nested macro in condition (false string)', async ({ page }) => {
1939 const input = '{{setvar::flag::false}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
1940 const output = await evaluateWithEngine(page, input);
1941 expect(output).toBe('[end]');
1942 });
1943 });
1944
1945 test.describe('scoped usage', () => {
1946 test('should work with scoped content (truthy)', async ({ page }) => {
1947 const input = '{{if yes}}This is the content{{/if}}';
1948 const output = await evaluateWithEngine(page, input);
1949 expect(output).toBe('This is the content');
1950 });
1951
1952 test('should work with scoped content (falsy)', async ({ page }) => {
1953 const input = '{{if::}}This should not show{{/if}}[after]';
1954 const output = await evaluateWithEngine(page, input);
1955 expect(output).toBe('[after]');
1956 });
1957
1958 test('should handle macros inside scoped content', async ({ page }) => {
1959 const input = '{{if yes}}Hello {{user}}!{{/if}}';
1960 const output = await evaluateWithEngine(page, input);
1961 expect(output).toBe('Hello User!');
1962 });
1963
1964 test('should handle nested if macros', async ({ page }) => {
1965 const input = '{{if yes}}outer{{if yes}}inner{{/if}}{{/if}}';
1966 const output = await evaluateWithEngine(page, input);
1967 expect(output).toBe('outerinner');
1968 });
1969
1970 test('should handle nested if with outer false', async ({ page }) => {
1971 const input = '{{if::}}outer{{if yes}}inner{{/if}}{{/if}}[end]';
1972 const output = await evaluateWithEngine(page, input);
1973 expect(output).toBe('[end]');
1974 });
1975
1976 test('should handle nested if with inner false', async ({ page }) => {
1977 const input = '{{if yes}}outer{{if::}}inner{{/if}}end{{/if}}';
1978 const output = await evaluateWithEngine(page, input);
1979 expect(output).toBe('outerend');
1980 });
1981 });
1982
1983 test.describe('with space-separated condition', () => {
1984 test('should work with space-separated condition (truthy)', async ({ page }) => {
1985 const input = '{{if something}}content{{/if}}';
1986 const output = await evaluateWithEngine(page, input);
1987 expect(output).toBe('content');
1988 });
1989
1990 test('should resolve macro name with space-separated syntax', async ({ page }) => {
1991 const input = '{{if char}}{{char}} exists{{/if}}';
1992 const output = await evaluateWithEngine(page, input);
1993 expect(output).toBe('Character exists');
1994 });
1995 });
1996
1997 test.describe('with {{else}} branch', () => {
1998 test('should return then-branch when condition is truthy', async ({ page }) => {
1999 const input = '{{if yes}}then{{else}}else{{/if}}';
2000 const output = await evaluateWithEngine(page, input);
2001 expect(output).toBe('then');
2002 });
2003
2004 test('should return else-branch when condition is falsy', async ({ page }) => {
2005 const input = '{{if::}}then{{else}}else{{/if}}';
2006 const output = await evaluateWithEngine(page, input);
2007 expect(output).toBe('else');
2008 });
2009
2010 test('should return else-branch when condition is "false"', async ({ page }) => {
2011 const input = '{{if::false}}yes{{else}}no{{/if}}';
2012 const output = await evaluateWithEngine(page, input);
2013 expect(output).toBe('no');
2014 });
2015
2016 test('should handle macros in both branches', async ({ page }) => {
2017 const input = '{{if yes}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
2018 const output = await evaluateWithEngine(page, input);
2019 expect(output).toBe('Hello User');
2020 });
2021
2022 test('should handle macros in else branch when falsy', async ({ page }) => {
2023 const input = '{{if::}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
2024 const output = await evaluateWithEngine(page, input);
2025 expect(output).toBe('Goodbye Character');
2026 });
2027
2028 test('should handle nested if-else in then-branch', async ({ page }) => {
2029 const input = '{{if yes}}outer-then{{if yes}}inner-then{{else}}inner-else{{/if}}{{else}}outer-else{{/if}}';
2030 const output = await evaluateWithEngine(page, input);
2031 expect(output).toBe('outer-theninner-then');
2032 });
2033
2034 test('should handle nested if-else in else-branch', async ({ page }) => {
2035 const input = '{{if::}}outer-then{{else}}outer-else{{if yes}}inner-then{{else}}inner-else{{/if}}{{/if}}';
2036 const output = await evaluateWithEngine(page, input);
2037 expect(output).toBe('outer-elseinner-then');
2038 });
2039
2040 test('should handle deeply nested if-else', async ({ page }) => {
2041 const input = '{{if::}}A{{else}}B{{if::}}C{{else}}D{{/if}}{{/if}}';
2042 const output = await evaluateWithEngine(page, input);
2043 expect(output).toBe('BD');
2044 });
2045
2046 test('should return empty else-branch if not provided', async ({ page }) => {
2047 const input = '{{if::}}content{{/if}}[end]';
2048 const output = await evaluateWithEngine(page, input);
2049 expect(output).toBe('[end]');
2050 });
2051
2052 test('should trim whitespace from branches', async ({ page }) => {
2053 const input = '{{if yes}} then {{else}} else {{/if}}';
2054 const output = await evaluateWithEngine(page, input);
2055 expect(output).toBe('then');
2056 });
2057
2058 test('should trim newlines from branches', async ({ page }) => {
2059 const input = '{{if yes}}\n then\n{{else}}\n else\n{{/if}}';
2060 const output = await evaluateWithEngine(page, input);
2061 expect(output).toBe('then');
2062 });
2063
2064 test('should trim else branch when selected', async ({ page }) => {
2065 const input = '{{if::}}\n then\n{{else}}\n else\n{{/if}}';
2066 const output = await evaluateWithEngine(page, input);
2067 expect(output).toBe('else');
2068 });
2069
2070 test('should resolve macro name in condition with else branch', async ({ page }) => {
2071 const input = '{{if char}}Has char{{else}}No char{{/if}}';
2072 const output = await evaluateWithEngine(page, input);
2073 expect(output).toBe('Has char');
2074 });
2075
2076 test('should handle empty macro returning else branch', async ({ page }) => {
2077 const input = '{{if noop}}Has value{{else}}Empty{{/if}}';
2078 const output = await evaluateWithEngine(page, input);
2079 expect(output).toBe('Empty');
2080 });
2081 });
2082
2083 test.describe('with inverted condition (!)', () => {
2084 test('should invert truthy condition to falsy', async ({ page }) => {
2085 const input = '{{if !yes}}shown{{/if}}[end]';
2086 const output = await evaluateWithEngine(page, input);
2087 expect(output).toBe('[end]');
2088 });
2089
2090 test('should invert falsy condition to truthy', async ({ page }) => {
2091 const input = '{{if !false}}shown{{/if}}';
2092 const output = await evaluateWithEngine(page, input);
2093 expect(output).toBe('shown');
2094 });
2095
2096 test('should invert empty string to truthy', async ({ page }) => {
2097 const input = '{{if::!}}not shown{{else}}shown{{/if}}';
2098 const output = await evaluateWithEngine(page, input);
2099 // Note: "!" is not empty, so it's truthy - but this tests literal ! as value
2100 expect(output).toBe('not shown');
2101 });
2102
2103 test('should work with ! prefix and macro name', async ({ page }) => {
2104 // noop returns empty string, so !noop should be truthy
2105 const input = '{{if !noop}}No value{{/if}}';
2106 const output = await evaluateWithEngine(page, input);
2107 expect(output).toBe('No value');
2108 });
2109
2110 test('should work with ! prefix and truthy macro', async ({ page }) => {
2111 // char returns "Character", so !char should be falsy
2112 const input = '{{if !char}}No char{{else}}Has char{{/if}}';
2113 const output = await evaluateWithEngine(page, input);
2114 expect(output).toBe('Has char');
2115 });
2116
2117 test('should work with ! prefix and nested macro', async ({ page }) => {
2118 // Set a variable to empty, then check !{{getvar}}
2119 const input = '{{setvar::emptyVar::}}{{if !{{getvar::emptyVar}}}}Empty var{{/if}}';
2120 const output = await evaluateWithEngine(page, input);
2121 expect(output).toBe('Empty var');
2122 });
2123
2124 test('should NOT invert when ! comes from resolved value', async ({ page }) => {
2125 // Set a variable starting with !, then check without ! prefix
2126 // The ! in the value should NOT cause inversion
2127 const input = '{{setvar::bangVar::!hello}}{{if {{getvar::bangVar}}}}Has value{{else}}No value{{/if}}';
2128 const output = await evaluateWithEngine(page, input);
2129 expect(output).toBe('Has value');
2130 });
2131
2132 test('should work with else branch on inverted condition', async ({ page }) => {
2133 const input = '{{if !yes}}then{{else}}else{{/if}}';
2134 const output = await evaluateWithEngine(page, input);
2135 expect(output).toBe('else');
2136 });
2137
2138 test('should work with separator syntax', async ({ page }) => {
2139 const input = '{{if::!something}}shown{{/if}}[end]';
2140 const output = await evaluateWithEngine(page, input);
2141 expect(output).toBe('[end]');
2142 });
2143 });
2144 });
2145
2146 test.describe('scoped content auto-trim', () => {
2147 test('should auto-trim scoped content by default', async ({ page }) => {
2148 const input = '{{setvar::myvar}}\n content with whitespace \n{{/setvar}}[{{getvar::myvar}}]';
2149 const output = await evaluateWithEngine(page, input);
2150 expect(output).toBe('[content with whitespace]');
2151 });
2152
2153 test('should auto-trim leading newlines in scoped content', async ({ page }) => {
2154 const input = '{{setvar::myvar}}\n\n\ntext{{/setvar}}[{{getvar::myvar}}]';
2155 const output = await evaluateWithEngine(page, input);
2156 expect(output).toBe('[text]');
2157 });
2158
2159 test('should auto-trim trailing newlines in scoped content', async ({ page }) => {
2160 const input = '{{setvar::myvar}}text\n\n\n{{/setvar}}[{{getvar::myvar}}]';
2161 const output = await evaluateWithEngine(page, input);
2162 expect(output).toBe('[text]');
2163 });
2164
2165 test('should dedent consistent indentation when auto-trimming', async ({ page }) => {
2166 // Both lines have 2-space indent, so dedent removes it from both
2167 const input = '{{setvar::myvar}}\n line1\n line2 \n{{/setvar}}[{{getvar::myvar}}]';
2168 const output = await evaluateWithEngine(page, input);
2169 expect(output).toBe('[line1\nline2]');
2170 });
2171
2172 test('should preserve whitespace with # flag', async ({ page }) => {
2173 const input = '{{#setvar::myvar}}\n content \n{{/setvar}}[{{getvar::myvar}}]';
2174 const output = await evaluateWithEngine(page, input);
2175 expect(output).toBe('[\n content \n]');
2176 });
2177
2178 test('should preserve leading newlines with # flag', async ({ page }) => {
2179 const input = '{{#setvar::myvar}}\n\ntext{{/setvar}}[{{getvar::myvar}}]';
2180 const output = await evaluateWithEngine(page, input);
2181 expect(output).toBe('[\n\ntext]');
2182 });
2183
2184 test('should preserve trailing newlines with # flag', async ({ page }) => {
2185 const input = '{{#setvar::myvar}}text\n\n{{/setvar}}[{{getvar::myvar}}]';
2186 const output = await evaluateWithEngine(page, input);
2187 expect(output).toBe('[text\n\n]');
2188 });
2189
2190 test('should work with # flag and nested macros', async ({ page }) => {
2191 const input = '{{#setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
2192 const output = await evaluateWithEngine(page, input);
2193 expect(output).toBe('[\n Character \n]');
2194 });
2195
2196 test('should auto-trim with nested macros by default', async ({ page }) => {
2197 const input = '{{setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
2198 const output = await evaluateWithEngine(page, input);
2199 expect(output).toBe('[Character]');
2200 });
2201
2202 test('should auto-trim {{if}} scoped content', async ({ page }) => {
2203 const input = '{{if yes}}\n trimmed \n{{/if}}';
2204 const output = await evaluateWithEngine(page, input);
2205 expect(output).toBe('trimmed');
2206 });
2207
2208 test('should preserve {{if}} whitespace with # flag', async ({ page }) => {
2209 const input = '{{#if yes}}\n preserved \n{{/if}}';
2210 const output = await evaluateWithEngine(page, input);
2211 // With # flag, both outer content AND branch trimming is skipped
2212 expect(output).toBe('\n preserved \n');
2213 });
2214
2215 test('should auto-trim {{reverse}} scoped content', async ({ page }) => {
2216 const input = '{{reverse}}\n abc \n{{/reverse}}';
2217 const output = await evaluateWithEngine(page, input);
2218 expect(output).toBe('cba');
2219 });
2220
2221 test('should preserve {{reverse}} whitespace with # flag', async ({ page }) => {
2222 const input = '{{#reverse}}\n abc \n{{/reverse}}';
2223 const output = await evaluateWithEngine(page, input);
2224 expect(output).toBe('\n cba \n');
2225 });
2226
2227 test('should dedent consistent indentation from multiline content', async ({ page }) => {
2228 const input = '{{setvar::myvar}}\n # Heading\n Content here\n{{/setvar}}[{{getvar::myvar}}]';
2229 const output = await evaluateWithEngine(page, input);
2230 expect(output).toBe('[# Heading\nContent here]');
2231 });
2232
2233 test('should dedent based on first non-empty line indentation', async ({ page }) => {
2234 const input = '{{setvar::myvar}}\n line1\n line2\n line3\n{{/setvar}}[{{getvar::myvar}}]';
2235 const output = await evaluateWithEngine(page, input);
2236 expect(output).toBe('[line1\nline2\nline3]');
2237 });
2238
2239 test('should preserve relative indentation when dedenting', async ({ page }) => {
2240 const input = '{{setvar::myvar}}\n parent\n child\n sibling\n{{/setvar}}[{{getvar::myvar}}]';
2241 const output = await evaluateWithEngine(page, input);
2242 expect(output).toBe('[parent\n child\nsibling]');
2243 });
2244
2245 test('should handle mixed indentation levels correctly', async ({ page }) => {
2246 const input = '{{setvar::myvar}}\n # Header\n - item1\n - item2\n Paragraph\n{{/setvar}}[{{getvar::myvar}}]';
2247 const output = await evaluateWithEngine(page, input);
2248 expect(output).toBe('[# Header\n - item1\n - item2\nParagraph]');
2249 });
2250
2251 test('should dedent {{if}} branches with indentation', async ({ page }) => {
2252 const input = '{{if yes}}\n # Title\n Body text\n{{/if}}';
2253 const output = await evaluateWithEngine(page, input);
2254 expect(output).toBe('# Title\nBody text');
2255 });
2256
2257 test('should dedent {{if}} else branch with indentation', async ({ page }) => {
2258 const input = '{{if false}}\n Then branch\n{{else}}\n # Else Title\n Else body\n{{/if}}';
2259 const output = await evaluateWithEngine(page, input);
2260 expect(output).toBe('# Else Title\nElse body');
2261 });
2262
2263 test('should not dedent when # flag is set', async ({ page }) => {
2264 const input = '{{#setvar::myvar}}\n # Heading\n Content\n{{/setvar}}[{{getvar::myvar}}]';
2265 const output = await evaluateWithEngine(page, input);
2266 expect(output).toBe('[\n # Heading\n Content\n]');
2267 });
2268
2269 test('should handle single line content without dedent issues', async ({ page }) => {
2270 const input = '{{setvar::myvar}}\n single line\n{{/setvar}}[{{getvar::myvar}}]';
2271 const output = await evaluateWithEngine(page, input);
2272 expect(output).toBe('[single line]');
2273 });
2274
2275 test('should handle empty lines in multiline content', async ({ page }) => {
2276 const input = '{{setvar::myvar}}\n line1\n\n line2\n{{/setvar}}[{{getvar::myvar}}]';
2277 const output = await evaluateWithEngine(page, input);
2278 expect(output).toBe('[line1\n\nline2]');
2279 });
2280
2281 test('should dedent based on first non-empty line and preserve relative indentation', async ({ page }) => {
2282 // First non-empty line has 2-space indent, subsequent lines have varying indentation
2283 // The 2-space base indent should be removed, preserving relative indentation
2284 const input = '{{setvar::myvar}}\n First Line\n Second Line, more indented\n Third line\n Fourth line, also more indented\n{{/setvar}}[{{getvar::myvar}}]';
2285 const output = await evaluateWithEngine(page, input);
2286 expect(output).toBe('[First Line\n Second Line, more indented\nThird line\n Fourth line, also more indented]');
2287 });
2288 });
2289
2290 test.describe('Pre/Post Processor Registration', () => {
2291 test('should run custom pre-processor before macro evaluation', async ({ page }) => {
2292 const output = await page.evaluate(async () => {
2293 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2294 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2295 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2296 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2297
2298 // Add a pre-processor that replaces [[USER]] with {{user}}
2299 const handler = (text) => text.replace(/\[\[USER\]\]/g, '{{user}}');
2300 MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:custom-user-marker' });
2301
2302 try {
2303 const input = 'Hello [[USER]]!';
2304 const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
2305 return MacroEngine.evaluate(input, env);
2306 } finally {
2307 MacroEngine.removePreProcessor(handler);
2308 }
2309 });
2310
2311 expect(output).toBe('Hello TestUser!');
2312 });
2313
2314 test('should run custom post-processor after macro evaluation', async ({ page }) => {
2315 const output = await page.evaluate(async () => {
2316 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2317 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2318 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2319 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2320
2321 // Add a post-processor that wraps output in brackets
2322 const handler = (text) => `[${text}]`;
2323 MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:bracket-wrapper' });
2324
2325 try {
2326 const input = 'Hello {{user}}!';
2327 const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
2328 return MacroEngine.evaluate(input, env);
2329 } finally {
2330 MacroEngine.removePostProcessor(handler);
2331 }
2332 });
2333
2334 expect(output).toBe('[Hello TestUser!]');
2335 });
2336
2337 test('should execute pre-processors in priority order (lower first)', async ({ page }) => {
2338 const output = await page.evaluate(async () => {
2339 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2340 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2341 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2342 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2343
2344 // First handler (priority 200) appends 'B'
2345 const handlerB = (text) => text + 'B';
2346 // Second handler (priority 100) appends 'A' - should run first despite being registered second
2347 const handlerA = (text) => text + 'A';
2348
2349 MacroEngine.addPreProcessor(handlerB, { priority: 200, source: 'test:append-b' });
2350 MacroEngine.addPreProcessor(handlerA, { priority: 100, source: 'test:append-a' });
2351
2352 try {
2353 const input = 'X';
2354 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
2355 return MacroEngine.evaluate(input, env);
2356 } finally {
2357 MacroEngine.removePreProcessor(handlerA);
2358 MacroEngine.removePreProcessor(handlerB);
2359 }
2360 });
2361
2362 // Priority 100 (A) runs before priority 200 (B), so: X -> XA -> XAB
2363 expect(output).toBe('XAB');
2364 });
2365
2366 test('should execute post-processors in priority order (lower first)', async ({ page }) => {
2367 const output = await page.evaluate(async () => {
2368 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2369 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2370 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2371 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2372
2373 // First handler (priority 200) wraps with ()
2374 const handlerParen = (text) => `(${text})`;
2375 // Second handler (priority 100) wraps with [] - should run first
2376 const handlerBracket = (text) => `[${text}]`;
2377
2378 MacroEngine.addPostProcessor(handlerParen, { priority: 200, source: 'test:wrap-paren' });
2379 MacroEngine.addPostProcessor(handlerBracket, { priority: 100, source: 'test:wrap-bracket' });
2380
2381 try {
2382 const input = 'X';
2383 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
2384 return MacroEngine.evaluate(input, env);
2385 } finally {
2386 MacroEngine.removePostProcessor(handlerBracket);
2387 MacroEngine.removePostProcessor(handlerParen);
2388 }
2389 });
2390
2391 // Priority 100 ([]) runs before priority 200 (()), so: X -> [X] -> ([X])
2392 expect(output).toBe('([X])');
2393 });
2394
2395 test('should successfully remove a registered pre-processor', async ({ page }) => {
2396 const output = await page.evaluate(async () => {
2397 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2398 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2399 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2400 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2401
2402 const handler = (text) => text + '-ADDED';
2403 MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:to-remove' });
2404
2405 // Remove it immediately
2406 const removed = MacroEngine.removePreProcessor(handler);
2407
2408 const input = 'Test';
2409 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
2410 const result = MacroEngine.evaluate(input, env);
2411
2412 return { result, removed };
2413 });
2414
2415 expect(output.removed).toBe(true);
2416 expect(output.result).toBe('Test'); // No '-ADDED' suffix
2417 });
2418
2419 test('should return false when removing non-existent processor', async ({ page }) => {
2420 const removed = await page.evaluate(async () => {
2421 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2422 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2423
2424 const handler = () => 'never registered';
2425 return MacroEngine.removePreProcessor(handler);
2426 });
2427
2428 expect(removed).toBe(false);
2429 });
2430
2431 test('should pass env to pre-processor handlers', async ({ page }) => {
2432 const output = await page.evaluate(async () => {
2433 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2434 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2435 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2436 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2437
2438 // Pre-processor that uses env to get the user name
2439 /** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
2440 const handler = (text, env) => text.replace('__NAME__', env.names.user);
2441 MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:env-access' });
2442
2443 try {
2444 const input = 'Hello __NAME__!';
2445 const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'EnvUser' });
2446 return MacroEngine.evaluate(input, env);
2447 } finally {
2448 MacroEngine.removePreProcessor(handler);
2449 }
2450 });
2451
2452 expect(output).toBe('Hello EnvUser!');
2453 });
2454
2455 test('should pass env to post-processor handlers', async ({ page }) => {
2456 const output = await page.evaluate(async () => {
2457 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2458 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2459 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2460 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2461
2462 // Post-processor that appends the character name from env
2463 /** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
2464 const handler = (text, env) => `${text} (by ${env.names.char})`;
2465 MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:env-access-post' });
2466
2467 try {
2468 const input = 'Message';
2469 const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name2Override: 'EnvChar' });
2470 return MacroEngine.evaluate(input, env);
2471 } finally {
2472 MacroEngine.removePostProcessor(handler);
2473 }
2474 });
2475
2476 expect(output).toBe('Message (by EnvChar)');
2477 });
2478 });
2479
2480 test.describe('Variable Shorthand Syntax', () => {
2481 // {{.myvar}} - get local variable
2482 test('should get local variable with . shorthand', async ({ page }) => {
2483 const output = await evaluateWithEngineAndVariables(page, '{{.myvar}}', { local: { myvar: 'hello' } });
2484 expect(output).toBe('hello');
2485 });
2486
2487 // {{$myvar}} - get global variable
2488 test('should get global variable with $ shorthand', async ({ page }) => {
2489 const output = await evaluateWithEngineAndVariables(page, '{{$myvar}}', { global: { myvar: 'world' } });
2490 expect(output).toBe('world');
2491 });
2492
2493 // {{.myvar = value}} - set local variable (setvar returns empty string)
2494 test('should set local variable with = shorthand', async ({ page }) => {
2495 const output = await evaluateWithEngineAndVariables(page, '{{.myvar = test}}Value: {{.myvar}}', { local: {} });
2496 // setvar returns '', then "Value: ", then getvar returns "test"
2497 expect(output).toBe('Value: test');
2498 });
2499
2500 // {{.counter++}} - increment local variable (incvar returns new value)
2501 test('should increment local variable with ++ shorthand', async ({ page }) => {
2502 const output = await evaluateWithEngineAndVariables(page, '{{.counter++}}', { local: { counter: '5' } });
2503 expect(output).toBe('6');
2504 });
2505
2506 // {{$counter--}} - decrement global variable (decvar returns new value)
2507 test('should decrement global variable with -- shorthand', async ({ page }) => {
2508 const output = await evaluateWithEngineAndVariables(page, '{{$counter--}}', { global: { counter: '10' } });
2509 expect(output).toBe('9');
2510 });
2511
2512 // {{.myvar += 5}} - add to local variable (addvar returns empty string)
2513 test('should add to local variable with += shorthand', async ({ page }) => {
2514 const output = await evaluateWithEngineAndVariables(page, '{{.myvar += 3}}Then: {{.myvar}}', { local: { myvar: '7' } });
2515 // addvar returns '', then "Then: ", then getvar returns "10"
2516 expect(output).toBe('Then: 10');
2517 });
2518
2519 // Nested macro in value: {{.myvar = {{user}}}}
2520 test('should support nested macro in variable value', async ({ page }) => {
2521 const output = await evaluateWithEngineAndVariables(page, '{{.greeting = Hello {{user}}}}{{.greeting}}', { local: {} });
2522 // setvar returns '', then getvar returns "Hello User"
2523 expect(output).toBe('Hello User');
2524 });
2525
2526 // Whitespace handling: {{ .myvar = value }}
2527 test('should handle whitespace in variable shorthand', async ({ page }) => {
2528 const output = await evaluateWithEngineAndVariables(page, '{{ .myvar = spaced }}{{.myvar}}', { local: {} });
2529 // setvar returns '', then getvar returns "spaced"
2530 expect(output).toBe('spaced');
2531 });
2532
2533 // Variable with hyphen in name: {{.my-var}}
2534 test('should handle variable name with hyphens', async ({ page }) => {
2535 const output = await evaluateWithEngineAndVariables(page, '{{.my-var}}', { local: { 'my-var': 'hyphenated' } });
2536 expect(output).toBe('hyphenated');
2537 });
2538
2539 // Variable with underscore: {{.my_var}}
2540 test('should handle variable name with underscores', async ({ page }) => {
2541 const output = await evaluateWithEngineAndVariables(page, '{{.my_var}}', { local: { 'my_var': 'underscored' } });
2542 expect(output).toBe('underscored');
2543 });
2544
2545 // Non-existent variable returns empty string
2546 test('should return empty string for non-existent variable', async ({ page }) => {
2547 const output = await evaluateWithEngineAndVariables(page, 'Value:[{{.nonexistent}}]', { local: {} });
2548 expect(output).toBe('Value:[]');
2549 });
2550
2551 // Increment non-existent variable (should start from 0)
2552 test('should increment non-existent variable starting from 0', async ({ page }) => {
2553 const output = await evaluateWithEngineAndVariables(page, '{{.newcounter++}}', { local: {} });
2554 expect(output).toBe('1');
2555 });
2556
2557 // Chain multiple operations
2558 test('should handle multiple variable operations in sequence', async ({ page }) => {
2559 const output = await evaluateWithEngineAndVariables(page, '{{.x = 5}}{{.x++}}{{.x += 10}}{{.x}}', { local: {} });
2560 // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
2561 expect(output).toBe('616');
2562 });
2563
2564 // {{.myvar -= 5}} - subtract from local variable
2565 test('should subtract from local variable with -= shorthand', async ({ page }) => {
2566 const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= 3}}Then: {{.myvar}}', { local: { myvar: '10' } });
2567 // subvar returns '', then "Then: ", then getvar returns "7"
2568 expect(output).toBe('Then: 7');
2569 });
2570
2571 // {{$myvar -= 5}} - subtract from global variable
2572 test('should subtract from global variable with -= shorthand', async ({ page }) => {
2573 const output = await evaluateWithEngineAndVariables(page, '{{$myvar -= 5}}{{$myvar}}', { global: { myvar: '20' } });
2574 expect(output).toBe('15');
2575 });
2576
2577 // {{.myvar || default}} - returns default when falsy
2578 test('should return default value with || when variable is falsy (empty)', async ({ page }) => {
2579 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '' } });
2580 expect(output).toBe('fallback');
2581 });
2582
2583 test('should return default value with || when variable is falsy (zero)', async ({ page }) => {
2584 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '0' } });
2585 expect(output).toBe('fallback');
2586 });
2587
2588 test('should return variable value with || when truthy', async ({ page }) => {
2589 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: 'existing' } });
2590 expect(output).toBe('existing');
2591 });
2592
2593 test('should return default value with || when variable does not exist', async ({ page }) => {
2594 const output = await evaluateWithEngineAndVariables(page, '{{.nonexistent || default}}', { local: {} });
2595 expect(output).toBe('default');
2596 });
2597
2598 // {{.myvar ?? default}} - returns default only when undefined
2599 test('should return default value with ?? when variable does not exist', async ({ page }) => {
2600 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: {} });
2601 expect(output).toBe('fallback');
2602 });
2603
2604 test('should return empty string with ?? when variable exists but is empty', async ({ page }) => {
2605 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? fallback}}]', { local: { myvar: '' } });
2606 expect(output).toBe('[]');
2607 });
2608
2609 test('should return zero with ?? when variable exists and is zero', async ({ page }) => {
2610 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: '0' } });
2611 expect(output).toBe('0');
2612 });
2613
2614 test('should return variable value with ?? when it exists', async ({ page }) => {
2615 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: 'value' } });
2616 expect(output).toBe('value');
2617 });
2618
2619 // {{.myvar ||= default}} - sets and returns default when falsy
2620 test('should set and return default with ||= when variable is falsy', async ({ page }) => {
2621 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: '' } });
2622 // ||= returns 'newval', then getvar also returns 'newval'
2623 expect(output).toBe('newvalnewval');
2624 });
2625
2626 test('should not set and return current with ||= when variable is truthy', async ({ page }) => {
2627 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: 'existing' } });
2628 // ||= returns 'existing', then getvar returns 'existing'
2629 expect(output).toBe('existingexisting');
2630 });
2631
2632 test('should set and return default with ||= when variable does not exist', async ({ page }) => {
2633 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= created}}{{.myvar}}', { local: {} });
2634 expect(output).toBe('createdcreated');
2635 });
2636
2637 // {{.myvar ??= default}} - sets and returns default only when undefined
2638 test('should set and return default with ??= when variable does not exist', async ({ page }) => {
2639 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= created}}{{.myvar}}', { local: {} });
2640 expect(output).toBe('createdcreated');
2641 });
2642
2643 test('should not set and return current with ??= when variable exists but is empty', async ({ page }) => {
2644 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ??= newval}}][{{.myvar}}]', { local: { myvar: '' } });
2645 // ??= returns '' (current value), then getvar returns '' (unchanged)
2646 expect(output).toBe('[][]');
2647 });
2648
2649 test('should not set and return current with ??= when variable exists and is zero', async ({ page }) => {
2650 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= newval}}{{.myvar}}', { local: { myvar: '0' } });
2651 // ??= returns '0', then getvar returns '0'
2652 expect(output).toBe('00');
2653 });
2654
2655 // {{.myvar == value}} - equality comparison
2656 test('should return true when variable equals value with ==', async ({ page }) => {
2657 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == hello}}', { local: { myvar: 'hello' } });
2658 expect(output).toBe('true');
2659 });
2660
2661 test('should return false when variable does not equal value with ==', async ({ page }) => {
2662 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == world}}', { local: { myvar: 'hello' } });
2663 expect(output).toBe('false');
2664 });
2665
2666 test('should compare empty variable correctly with ==', async ({ page }) => {
2667 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ==}}', { local: { myvar: '' } });
2668 expect(output).toBe('true');
2669 });
2670
2671 test('should compare numeric value correctly with ==', async ({ page }) => {
2672 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == 42}}', { local: { myvar: '42' } });
2673 expect(output).toBe('true');
2674 });
2675
2676 // {{.myvar != value}} - inequality comparison
2677 test('should return true when variable does not equal value with !=', async ({ page }) => {
2678 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != world}}', { local: { myvar: 'hello' } });
2679 expect(output).toBe('true');
2680 });
2681
2682 test('should return false when variable equals value with !=', async ({ page }) => {
2683 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != hello}}', { local: { myvar: 'hello' } });
2684 expect(output).toBe('false');
2685 });
2686
2687 test('should compare empty variable correctly with !=', async ({ page }) => {
2688 const output = await evaluateWithEngineAndVariables(page, '{{.myvar !=}}', { local: { myvar: '' } });
2689 expect(output).toBe('false');
2690 });
2691
2692 test('should compare non-empty to empty with !=', async ({ page }) => {
2693 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != }}', { local: { myvar: 'value' } });
2694 expect(output).toBe('true');
2695 });
2696
2697 test('should compare numeric value correctly with !=', async ({ page }) => {
2698 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != 99}}', { local: { myvar: '42' } });
2699 expect(output).toBe('true');
2700 });
2701
2702 // {{.myvar > value}} - greater than comparison (numeric)
2703 test('should return true when variable is greater than value with >', async ({ page }) => {
2704 const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: '10' } });
2705 expect(output).toBe('true');
2706 });
2707
2708 test('should return false when variable is not greater than value with >', async ({ page }) => {
2709 const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '5' } });
2710 expect(output).toBe('false');
2711 });
2712
2713 test('should return false when variable equals value with >', async ({ page }) => {
2714 const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '10' } });
2715 expect(output).toBe('false');
2716 });
2717
2718 test('should return false for non-numeric values with >', async ({ page }) => {
2719 const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: 'abc' } });
2720 expect(output).toBe('false');
2721 });
2722
2723 // {{.myvar >= value}} - greater than or equal comparison (numeric)
2724 test('should return true when variable is greater than value with >=', async ({ page }) => {
2725 const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 5}}', { local: { myvar: '10' } });
2726 expect(output).toBe('true');
2727 });
2728
2729 test('should return true when variable equals value with >=', async ({ page }) => {
2730 const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '10' } });
2731 expect(output).toBe('true');
2732 });
2733
2734 test('should return false when variable is less than value with >=', async ({ page }) => {
2735 const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '5' } });
2736 expect(output).toBe('false');
2737 });
2738
2739 // {{.myvar < value}} - less than comparison (numeric)
2740 test('should return true when variable is less than value with <', async ({ page }) => {
2741 const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '5' } });
2742 expect(output).toBe('true');
2743 });
2744
2745 test('should return false when variable is not less than value with <', async ({ page }) => {
2746 const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: '10' } });
2747 expect(output).toBe('false');
2748 });
2749
2750 test('should return false when variable equals value with <', async ({ page }) => {
2751 const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '10' } });
2752 expect(output).toBe('false');
2753 });
2754
2755 test('should return false for non-numeric values with <', async ({ page }) => {
2756 const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: 'abc' } });
2757 expect(output).toBe('false');
2758 });
2759
2760 // {{.myvar <= value}} - less than or equal comparison (numeric)
2761 test('should return true when variable is less than value with <=', async ({ page }) => {
2762 const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '5' } });
2763 expect(output).toBe('true');
2764 });
2765
2766 test('should return true when variable equals value with <=', async ({ page }) => {
2767 const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '10' } });
2768 expect(output).toBe('true');
2769 });
2770
2771 test('should return false when variable is greater than value with <=', async ({ page }) => {
2772 const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 5}}', { local: { myvar: '10' } });
2773 expect(output).toBe('false');
2774 });
2775
2776 // Negative numbers with comparison operators
2777 test('should handle negative numbers with > operator', async ({ page }) => {
2778 const output = await evaluateWithEngineAndVariables(page, '{{.myvar > -5}}', { local: { myvar: '0' } });
2779 expect(output).toBe('true');
2780 });
2781
2782 test('should handle negative numbers with < operator', async ({ page }) => {
2783 const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 0}}', { local: { myvar: '-5' } });
2784 expect(output).toBe('true');
2785 });
2786
2787 // Decimal numbers with comparison operators
2788 test('should handle decimal numbers with >= operator', async ({ page }) => {
2789 const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 3.14}}', { local: { myvar: '3.14' } });
2790 expect(output).toBe('true');
2791 });
2792
2793 test('should handle decimal numbers with <= operator', async ({ page }) => {
2794 const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 2.5}}', { local: { myvar: '2.49' } });
2795 expect(output).toBe('true');
2796 });
2797
2798 // Global variable versions of new operators
2799 test('should use || with global variable', async ({ page }) => {
2800 const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });
2801 expect(output).toBe('globaldefault');
2802 });
2803
2804 test('should use ?? with global variable', async ({ page }) => {
2805 const output = await evaluateWithEngineAndVariables(page, '{{$myvar ?? globaldefault}}', { global: {} });
2806 expect(output).toBe('globaldefault');
2807 });
2808
2809 test('should use ||= with global variable', async ({ page }) => {
2810 const output = await evaluateWithEngineAndVariables(page, '{{$myvar ||= gset}}{{$myvar}}', { global: { myvar: '' } });
2811 expect(output).toBe('gsetgset');
2812 });
2813
2814 test('should use ??= with global variable', async ({ page }) => {
2815 const output = await evaluateWithEngineAndVariables(page, '{{$myvar ??= gcreated}}{{$myvar}}', { global: {} });
2816 expect(output).toBe('gcreatedgcreated');
2817 });
2818
2819 test('should use == with global variable', async ({ page }) => {
2820 const output = await evaluateWithEngineAndVariables(page, '{{$myvar == test}}', { global: { myvar: 'test' } });
2821 expect(output).toBe('true');
2822 });
2823
2824 // Nested macro in fallback value
2825 test('should support nested macro in || fallback value', async ({ page }) => {
2826 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || Hello {{user}}}}', { local: {} });
2827 expect(output).toBe('Hello User');
2828 });
2829
2830 test('should support nested macro in ?? fallback value', async ({ page }) => {
2831 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? Hello {{user}}}}', { local: {} });
2832 expect(output).toBe('Hello User');
2833 });
2834
2835 // Whitespace handling with new operators
2836 test('should handle whitespace with || operator', async ({ page }) => {
2837 const output = await evaluateWithEngineAndVariables(page, '{{ .myvar || spaced }}', { local: {} });
2838 expect(output).toBe('spaced');
2839 });
2840
2841 test('should handle whitespace with ?? operator', async ({ page }) => {
2842 const output = await evaluateWithEngineAndVariables(page, '{{ .myvar ?? spaced }}', { local: {} });
2843 expect(output).toBe('spaced');
2844 });
2845 });
2846
2847 test.describe('Variable Shorthand Lazy Evaluation', () => {
2848 // Tests to verify that fallback value expressions are only evaluated when needed.
2849 // This is important for performance and because some macros are stateful.
2850
2851 // ?? should NOT evaluate fallback when variable exists
2852 test('should NOT evaluate ?? fallback when variable exists', async ({ page }) => {
2853 // Use setvar in the fallback - if lazy evaluation works, tracker should remain unset
2854 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'exists' } });
2855 // myvar exists, so ?? returns 'exists' and the fallback (which would set tracker) is NOT evaluated
2856 expect(output).toBe('exists[]');
2857 });
2858
2859 test('should evaluate ?? fallback when variable does not exist', async ({ page }) => {
2860 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: {} });
2861 // myvar doesn't exist, so ?? evaluates and returns the fallback, setting tracker
2862 expect(output).toBe('fallback[evaluated]');
2863 });
2864
2865 // || should NOT evaluate fallback when variable is truthy
2866 test('should NOT evaluate || fallback when variable is truthy', async ({ page }) => {
2867 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
2868 // myvar is truthy, so || returns 'truthy' and the fallback is NOT evaluated
2869 expect(output).toBe('truthy[]');
2870 });
2871
2872 test('should evaluate || fallback when variable is falsy', async ({ page }) => {
2873 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: '' } });
2874 // myvar is falsy, so || evaluates and returns the fallback, setting tracker
2875 expect(output).toBe('fallback[evaluated]');
2876 });
2877
2878 // ??= should NOT evaluate value when variable exists
2879 test('should NOT evaluate ??= value when variable exists', async ({ page }) => {
2880 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'exists' } });
2881 // myvar exists, so ??= returns current value and the value expression is NOT evaluated
2882 expect(output).toBe('exists[]');
2883 });
2884
2885 test('should evaluate ??= value when variable does not exist', async ({ page }) => {
2886 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: {} });
2887 // myvar doesn't exist, so ??= evaluates value, sets myvar, and returns it
2888 expect(output).toBe('newval[evaluated]');
2889 });
2890
2891 // ||= should NOT evaluate value when variable is truthy
2892 test('should NOT evaluate ||= value when variable is truthy', async ({ page }) => {
2893 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
2894 // myvar is truthy, so ||= returns current value and the value expression is NOT evaluated
2895 expect(output).toBe('truthy[]');
2896 });
2897
2898 test('should evaluate ||= value when variable is falsy', async ({ page }) => {
2899 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: '' } });
2900 // myvar is falsy, so ||= evaluates value, sets myvar, and returns it
2901 expect(output).toBe('newval[evaluated]');
2902 });
2903
2904 // Operators that ALWAYS evaluate value should still work
2905 test('should always evaluate = value expression', async ({ page }) => {
2906 const output = await evaluateWithEngineAndVariables(page, '{{.myvar = {{.tracker = evaluated}}value}}[{{.tracker}}]', { local: {} });
2907 expect(output).toBe('[evaluated]');
2908 });
2909
2910 test('should always evaluate += value expression', async ({ page }) => {
2911 const output = await evaluateWithEngineAndVariables(page, '{{.myvar += {{.tracker = evaluated}}5}}[{{.tracker}}]', { local: { myvar: '10' } });
2912 expect(output).toBe('[evaluated]');
2913 });
2914
2915 test('should always evaluate == value expression', async ({ page }) => {
2916 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == {{.tracker = evaluated}}test}}[{{.tracker}}]', { local: { myvar: 'test' } });
2917 expect(output).toBe('true[evaluated]');
2918 });
2919
2920 // Value should only be evaluated once (caching test)
2921 test('should only evaluate value expression once when needed', async ({ page }) => {
2922 // Use addvar to track how many times the value is evaluated (addvar returns empty string)
2923 const output = await evaluateWithEngineAndVariables(page, '{{.counter = 0}}{{.myvar ??= {{.counter += 1}}value}}{{.counter}}', { local: {} });
2924 // counter should be 1 (value evaluated exactly once)
2925 expect(output).toBe('value1');
2926 });
2927 });
2928
2929 test.describe('Variable Shorthand Edge Cases', () => {
2930 // Operators requiring a value but value is empty
2931 test('should handle = operator with empty value', async ({ page }) => {
2932 const output = await evaluateWithEngineAndVariables(page, '{{.myvar = }}[{{.myvar}}]', { local: {} });
2933 // Empty value after = should set the variable to empty string
2934 expect(output).toBe('[]');
2935 });
2936
2937 test('should handle += operator with empty value', async ({ page }) => {
2938 const output = await evaluateWithEngineAndVariables(page, '{{.myvar += }}[{{.myvar}}]', { local: { myvar: 'existing' } });
2939 // Empty value after += should add nothing
2940 expect(output).toBe('[existing]');
2941 });
2942
2943 test('should handle -= operator with empty value (non-numeric)', async ({ page }) => {
2944 const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= }}[{{.myvar}}]', { local: { myvar: '10' } });
2945 // Empty value is NaN, so subtraction fails silently and returns empty
2946 expect(output).toBe('[10]');
2947 });
2948
2949 test('should handle || operator with empty fallback', async ({ page }) => {
2950 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar || }}]', { local: { myvar: '' } });
2951 // Falsy myvar, empty fallback - returns empty string
2952 expect(output).toBe('[]');
2953 });
2954
2955 test('should handle ?? operator with empty fallback', async ({ page }) => {
2956 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? }}]', { local: {} });
2957 // Undefined myvar, empty fallback - returns empty string
2958 expect(output).toBe('[]');
2959 });
2960
2961 test('should handle == operator with empty comparison value', async ({ page }) => {
2962 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: '' } });
2963 // Empty var equals empty value - should be true
2964 expect(output).toBe('true');
2965 });
2966
2967 test('should handle == operator comparing non-empty to empty', async ({ page }) => {
2968 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: 'value' } });
2969 // Non-empty var vs empty value - should be false
2970 expect(output).toBe('false');
2971 });
2972
2973 // Operators that don't take values - should return raw if invalid
2974 test('should return raw with trailing content after ++ operator', async ({ page }) => {
2975 const output = await evaluateWithEngineAndVariables(page, '{{.myvar++5}}', { local: { myvar: '5' } });
2976 expect(output).toBe('{{.myvar++5}}');
2977 });
2978
2979 test('should return empty with trailing content after -- operator', async ({ page }) => {
2980 // This is a weird case. The "--" operator does not accept value expression, but writing it like this,
2981 // makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
2982 // This is intended, so this resolving to null, as the variable does not exist, is also intended.
2983 const output = await evaluateWithEngineAndVariables(page, '{{.myvar--5}}', { local: { myvar: '10' } });
2984 expect(output).toBe('');
2985 });
2986
2987 test('should return raw with trailing content after -- operator separated by spaces', async ({ page }) => {
2988 // This is a weird case. The "--" operator does not accept value expression, but writing it like this,
2989 // makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
2990 // This is intended, so this resolving to null, as the variable does not exist, is also intended.
2991 const output = await evaluateWithEngineAndVariables(page, '{{.myvar -- 5}}', { local: { myvar: '10' } });
2992 expect(output).toBe('{{.myvar -- 5}}');
2993 });
2994 });
2995
2996 test.describe('Variable Shorthand in {{if}} Macro', () => {
2997 // {{if .myvar}}...{{/if}} - truthy local variable
2998 test('should evaluate truthy local variable in if condition', async ({ page }) => {
2999 const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '1' } });
3000 expect(output).toBe('Yes');
3001 });
3002
3003 // {{if .myvar}}...{{/if}} - falsy local variable
3004 test('should evaluate falsy local variable in if condition', async ({ page }) => {
3005 const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '' } });
3006 expect(output).toBe('');
3007 });
3008
3009 // {{if $globalvar}}...{{/if}} - truthy global variable
3010 test('should evaluate truthy global variable in if condition', async ({ page }) => {
3011 const output = await evaluateWithEngineAndVariables(page, '{{if $enabled}}Active{{/if}}', { global: { enabled: 'true' } });
3012 expect(output).toBe('Active');
3013 });
3014
3015 // {{if !.myvar}}...{{/if}} - inverted condition
3016 test('should evaluate inverted variable condition', async ({ page }) => {
3017 const output = await evaluateWithEngineAndVariables(page, '{{if !.flag}}Not set{{/if}}', { local: { flag: '' } });
3018 expect(output).toBe('Not set');
3019 });
3020
3021 // {{if !$globalvar}}...{{/if}} - inverted global
3022 test('should evaluate inverted global variable condition', async ({ page }) => {
3023 const output = await evaluateWithEngineAndVariables(page, '{{if !$disabled}}Enabled{{/if}}', { global: { disabled: '' } });
3024 expect(output).toBe('Enabled');
3025 });
3026
3027 // {{if ! .myvar}}...{{/if}} - inverted with whitespace
3028 test('should evaluate inverted condition with whitespace after !', async ({ page }) => {
3029 const output = await evaluateWithEngineAndVariables(page, '{{if ! .empty}}Empty{{/if}}', { local: { empty: '' } });
3030 expect(output).toBe('Empty');
3031 });
3032
3033 // Non-existent variable is falsy
3034 test('should treat non-existent variable as falsy in if condition', async ({ page }) => {
3035 const output = await evaluateWithEngineAndVariables(page, '{{if .nonexistent}}Yes{{else}}No{{/if}}', { local: {} });
3036 expect(output).toBe('No');
3037 });
3038
3039 // {{if .myvar}}...{{else}}...{{/if}} - with else branch
3040 test('should handle else branch with variable shorthand', async ({ page }) => {
3041 const output = await evaluateWithEngineAndVariables(page, '{{if .active}}On{{else}}Off{{/if}}', { local: { active: 'yes' } });
3042 expect(output).toBe('On');
3043 });
3044
3045 // Variable with hyphen in if condition
3046 test('should handle variable with hyphen in if condition', async ({ page }) => {
3047 const output = await evaluateWithEngineAndVariables(page, '{{if .is-valid}}Valid{{/if}}', { local: { 'is-valid': '1' } });
3048 expect(output).toBe('Valid');
3049 });
3050
3051 // Combine set and if
3052 test('should work with variable set before if check', async ({ page }) => {
3053 const output = await evaluateWithEngineAndVariables(page, '{{.ready = yes}}{{if .ready}}Ready!{{/if}}', { local: {} });
3054 expect(output).toBe('Ready!');
3055 });
3056
3057 // Zero is falsy
3058 test('should treat zero as falsy in if condition', async ({ page }) => {
3059 const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Has count{{else}}No count{{/if}}', { local: { count: '0' } });
3060 expect(output).toBe('No count');
3061 });
3062
3063 // Non-zero number is truthy
3064 test('should treat non-zero number as truthy in if condition', async ({ page }) => {
3065 const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Count: {{.count}}{{/if}}', { local: { count: '42' } });
3066 expect(output).toBe('Count: 42');
3067 });
3068 });
3069
3070 const getUniqueVariableId = () => `dt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3071
3072 test.describe('Delayed Argument Resolution ({{if}} branch isolation)', () => {
3073 // Core feature: setvar in non-chosen branch should NOT execute
3074 test('should NOT execute setvar in false branch', async ({ page }) => {
3075 const id = getUniqueVariableId();
3076 const output = await page.evaluate(async (id) => {
3077 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3078 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3079 const ctx = SillyTavern.getContext();
3080
3081 ctx.variables.local.del(id);
3082
3083 const input = `{{if 0}}{{setvar::${id}::should-not-set}}{{/if}}`;
3084 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3085 MacroEngine.evaluate(input, env);
3086
3087 // Variable should NOT be set because the branch was not taken
3088 const result = ctx.variables.local.get(id);
3089 ctx.variables.local.del(id);
3090 return result;
3091 }, id);
3092
3093 expect(output).toBe('');
3094 });
3095
3096 // setvar in true branch SHOULD execute
3097 test('should execute setvar in true branch', async ({ page }) => {
3098 const id = getUniqueVariableId();
3099 const output = await page.evaluate(async (id) => {
3100 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3101 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3102 const ctx = SillyTavern.getContext();
3103
3104 ctx.variables.local.del(id);
3105
3106 const input = `{{if 1}}{{setvar::${id}::was-set}}{{/if}}`;
3107 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3108 MacroEngine.evaluate(input, env);
3109
3110 const result = ctx.variables.local.get(id);
3111 ctx.variables.local.del(id);
3112 return result;
3113 }, id);
3114
3115 expect(output).toBe('was-set');
3116 });
3117
3118 // With else branch: only the chosen branch's setvar should execute
3119 test('should only execute setvar in chosen else branch', async ({ page }) => {
3120 const id = getUniqueVariableId();
3121 const output = await page.evaluate(async (id) => {
3122 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3123 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3124 const ctx = SillyTavern.getContext();
3125
3126 ctx.variables.local.del(id);
3127
3128 const input = `{{if 0}}{{setvar::${id}::then-branch}}{{else}}{{setvar::${id}::else-branch}}{{/if}}`;
3129 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3130 MacroEngine.evaluate(input, env);
3131
3132 const result = ctx.variables.local.get(id);
3133 ctx.variables.local.del(id);
3134 return result;
3135 }, id);
3136
3137 expect(output).toBe('else-branch');
3138 });
3139
3140 // Verify then branch setvar executes, not else branch
3141 test('should only execute setvar in chosen then branch', async ({ page }) => {
3142 const id = getUniqueVariableId();
3143 const output = await page.evaluate(async (id) => {
3144 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3145 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3146 const ctx = SillyTavern.getContext();
3147
3148 ctx.variables.local.del(id);
3149
3150 const input = `{{if 1}}{{setvar::${id}::then-branch}}{{else}}{{setvar::${id}::else-branch}}{{/if}}`;
3151 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3152 MacroEngine.evaluate(input, env);
3153
3154 const result = ctx.variables.local.get(id);
3155 ctx.variables.local.del(id);
3156 return result;
3157 }, id);
3158
3159 expect(output).toBe('then-branch');
3160 });
3161
3162 // Multiple setvars in branches - only chosen branch's setvars execute
3163 test('should execute multiple setvars only in chosen branch', async ({ page }) => {
3164 const id = getUniqueVariableId();
3165 const output = await page.evaluate(async (id) => {
3166 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3167 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3168 const ctx = SillyTavern.getContext();
3169
3170 ctx.variables.local.del(`${id}_a`);
3171 ctx.variables.local.del(`${id}_b`);
3172
3173 const input = `{{if 0}}{{setvar::${id}_a::wrong}}{{setvar::${id}_b::wrong}}{{else}}{{setvar::${id}_a::right}}{{setvar::${id}_b::right}}{{/if}}`;
3174 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3175 MacroEngine.evaluate(input, env);
3176
3177 const a = ctx.variables.local.get(`${id}_a`);
3178 const b = ctx.variables.local.get(`${id}_b`);
3179 ctx.variables.local.del(`${id}_a`);
3180 ctx.variables.local.del(`${id}_b`);
3181 return `a=${a},b=${b}`;
3182 }, id);
3183
3184 expect(output).toBe('a=right,b=right');
3185 });
3186
3187 // Nested if with delayed resolution - inner if should also work correctly
3188 test('should handle nested if with delayed resolution', async ({ page }) => {
3189 const id = `dt_nested_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3190 const output = await page.evaluate(async (id) => {
3191 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3192 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3193 const ctx = SillyTavern.getContext();
3194
3195 ctx.variables.local.del(`${id}_outer`);
3196 ctx.variables.local.del(`${id}_inner`);
3197
3198 // Outer if is true, inner if is false
3199 const input = `{{if 1}}{{setvar::${id}_outer::yes}}{{if 0}}{{setvar::${id}_inner::wrong}}{{else}}{{setvar::${id}_inner::correct}}{{/if}}{{/if}}`;
3200 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3201 MacroEngine.evaluate(input, env);
3202
3203 const outer = ctx.variables.local.get(`${id}_outer`);
3204 const inner = ctx.variables.local.get(`${id}_inner`);
3205 ctx.variables.local.del(`${id}_outer`);
3206 ctx.variables.local.del(`${id}_inner`);
3207 return `outer=${outer},inner=${inner}`;
3208 }, id);
3209
3210 expect(output).toBe('outer=yes,inner=correct');
3211 });
3212
3213 // Variable-based condition with delayed resolution
3214 test('should work with variable shorthand condition and delayed resolution', async ({ page }) => {
3215 const id = `dt_varsh_${Date.now()}_${Math.random().toString(36).slice(2)}`;
3216 const output = await page.evaluate(async (id) => {
3217 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3218 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3219 const ctx = SillyTavern.getContext();
3220
3221 ctx.variables.local.set(`${id}_flag`, '');
3222 ctx.variables.local.del(`${id}_result`);
3223
3224 const input = `{{if .${id}_flag}}{{setvar::${id}_result::truthy}}{{else}}{{setvar::${id}_result::falsy}}{{/if}}`;
3225 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
3226 MacroEngine.evaluate(input, env);
3227
3228 const result = ctx.variables.local.get(`${id}_result`);
3229 ctx.variables.local.del(`${id}_flag`);
3230 ctx.variables.local.del(`${id}_result`);
3231 return result;
3232 }, id);
3233
3234 expect(output).toBe('falsy');
3235 });
3236
3237 // Inline {{if}} should not break outer {{else}} detection
3238 test('should handle inline if inside scoped if with else', async ({ page }) => {
3239 const output = await evaluateWithEngine(page, '{{if 0}}{{if::1::inner}}{{else}}outer-else{{/if}}');
3240 expect(output).toBe('outer-else');
3241 });
3242
3243 // Another inline if scenario - inner inline if should not affect outer else
3244 test('should correctly find outer else with multiple inline ifs', async ({ page }) => {
3245 const output = await evaluateWithEngine(page, '{{if 0}}{{if::1::a}}{{if::1::b}}{{else}}found{{/if}}');
3246 expect(output).toBe('found');
3247 });
3248 });
3249
3250 test.describe('Variable Macros (hasvar, deletevar)', () => {
3251 // {{hasvar::name}} - check if local variable exists
3252 test('should return true when local variable exists', async ({ page }) => {
3253 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: 'value' } });
3254 expect(output).toBe('true');
3255 });
3256
3257 test('should return false when local variable does not exist', async ({ page }) => {
3258 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::nonexistent}}', { local: {} });
3259 expect(output).toBe('false');
3260 });
3261
3262 test('should return true when local variable exists but is empty', async ({ page }) => {
3263 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: '' } });
3264 expect(output).toBe('true');
3265 });
3266
3267 // {{hasglobalvar::name}} - check if global variable exists
3268 test('should return true when global variable exists', async ({ page }) => {
3269 const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
3270 expect(output).toBe('true');
3271 });
3272
3273 test('should return false when global variable does not exist', async ({ page }) => {
3274 const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::nonexistent}}', { global: {} });
3275 expect(output).toBe('false');
3276 });
3277
3278 // {{deletevar::name}} - delete local variable
3279 test('should delete local variable', async ({ page }) => {
3280 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}{{deletevar::myvar}}{{hasvar::myvar}}', { local: { myvar: 'value' } });
3281 expect(output).toBe('truefalse');
3282 });
3283
3284 // {{deleteglobalvar::name}} - delete global variable
3285 test('should delete global variable', async ({ page }) => {
3286 const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}{{deleteglobalvar::myvar}}{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
3287 expect(output).toBe('truefalse');
3288 });
3289
3290 // Combining hasvar with if
3291 test('should use hasvar in if condition', async ({ page }) => {
3292 const output = await evaluateWithEngineAndVariables(page, '{{if {{hasvar::myvar}} == true}}exists{{else}}missing{{/if}}', { local: { myvar: '' } });
3293 expect(output).toBe('exists');
3294 });
3295 });
3296});
3297
3298/**
3299 * Evaluates the given input string using the MacroEngine inside the browser
3300 * context, ensuring that the core macros are registered.
3301 *
3302 * @param {import('@playwright/test').Page} page
3303 * @param {string} input
3304 * @returns {Promise<string>}
3305 */
3306async function evaluateWithEngine(page, input) {
3307 const result = await page.evaluate(async (input) => {
3308 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
3309 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3310 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
3311 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3312
3313 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
3314 const rawEnv = {
3315 content: input,
3316 name1Override: 'User',
3317 name2Override: 'Character',
3318 };
3319 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
3320
3321 const output = await MacroEngine.evaluate(input, env);
3322 return output;
3323 }, input);
3324
3325 return result;
3326}
3327
3328/**
3329 * Evaluates the given input string while capturing whether any macro-related
3330 * warnings or errors were logged to the browser console.
3331 *
3332 * This is useful for tests that want to assert both the resolved output and
3333 * whether the lexer/parser/engine reported issues (e.g. unterminated macros).
3334 *
3335 * @param {import('@playwright/test').Page} page
3336 * @param {string} input
3337 * @returns {Promise<{ output: string, hasMacroWarnings: boolean, hasMacroErrors: boolean }>}
3338 */
3339async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
3340 /** @type {boolean} */
3341 let hasMacroWarnings = false;
3342 /** @type {boolean} */
3343 let hasMacroErrors = false;
3344
3345 /** @param {import('playwright').ConsoleMessage} msg */
3346 const handler = (msg) => {
3347 const text = msg.text();
3348 if (text.includes('[Macro] Warning:')) {
3349 hasMacroWarnings = true;
3350 }
3351 if (text.includes('[Macro] Error:')) {
3352 hasMacroErrors = true;
3353 }
3354 };
3355
3356 page.on('console', handler);
3357 try {
3358 const output = await evaluateWithEngine(page, input);
3359 return { output, hasMacroWarnings, hasMacroErrors };
3360 } finally {
3361 page.off('console', handler);
3362 }
3363}
3364
3365/**
3366 * Evaluates the given input string with pre-set variables.
3367 * Variables are set via SillyTavern.getContext().variables which is where
3368 * the variable macros read/write their data.
3369 *
3370 * @param {import('@playwright/test').Page} page
3371 * @param {string} input
3372 * @param {{ local?: Record<string, string>, global?: Record<string, string> }} variables
3373 * @returns {Promise<string>}
3374 */
3375async function evaluateWithEngineAndVariables(page, input, variables) {
3376 const result = await page.evaluate(async ({ input, variables }) => {
3377 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
3378 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
3379 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
3380 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
3381
3382 // Get the SillyTavern context for variable access
3383 const ctx = SillyTavern.getContext();
3384
3385 // Pre-set local variables
3386 if (variables.local) {
3387 for (const [key, value] of Object.entries(variables.local)) {
3388 ctx.variables.local.set(key, value);
3389 }
3390 }
3391 // Pre-set global variables
3392 if (variables.global) {
3393 for (const [key, value] of Object.entries(variables.global)) {
3394 ctx.variables.global.set(key, value);
3395 }
3396 }
3397
3398 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
3399 const rawEnv = {
3400 content: input,
3401 name1Override: 'User',
3402 name2Override: 'Character',
3403 };
3404 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
3405
3406 const output = await MacroEngine.evaluate(input, env);
3407 return output;
3408 }, { input, variables });
3409
3410 return result;
3411}