42155ecebf
* feat: Add numeric comparison operators (>, >=, <, <=) to variable shorthand syntax - Added four new comparison operators for local and global variables - Implemented greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual operations in MacroCstWalker - Updated lexer to recognize >=, >, <=, < operators (longer patterns before shorter to avoid conflicts) - Simplified parser by consolidating operator alternatives into single OR block - Enhanced autocomplete with operator definitions, examples, and usage * Improve autocomplete for variable shorthand operators with cursor-aware filtering - Track `variableNameEnd` and `variableOperatorEnd` positions in parsed macro context for accurate cursor position checks - Add `isShortOperatorPrefix()` helper to detect operators that could be prefixes of longer ones (e.g., `>` → `>=`) - Fix operator autocomplete to show longer variants when typing short operators (typing `>` now shows both `>` and `>=`) - Show operator suggestions immediately when variable
3412 lines
172 KiB
JavaScript
3412 lines
172 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
|
import { testSetup } from './frontent-test-utils.js';
|
|
|
|
test.describe('MacroEngine', () => {
|
|
test.beforeEach(testSetup.awaitST);
|
|
|
|
test.describe('Basic evaluation', () => {
|
|
test('should return input unchanged when there are no macros', async ({ page }) => {
|
|
const input = 'Hello world, no macros here.';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(input);
|
|
});
|
|
|
|
test('should evaluate a simple macro without arguments', async ({ page }) => {
|
|
const input = 'Start {{newline}} end.';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Start \n end.');
|
|
});
|
|
|
|
test('should evaluate multiple macros in order', async ({ page }) => {
|
|
const input = 'A {{setvar::test::4}}{{getvar::test}} B {{setvar::test::2}}{{getvar::test}} C';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('A 4 B 2 C');
|
|
});
|
|
});
|
|
|
|
test.describe('Unnamed arguments', () => {
|
|
test('should handle normal double-colon separated unnamed argument', async ({ page }) => {
|
|
const input = 'Reversed: {{reverse::abc}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Reversed: cba!');
|
|
});
|
|
|
|
test('should handle (legacy) colon separated unnamed argument', async ({ page }) => {
|
|
const input = 'Reversed: {{reverse:abc}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Reversed: cba!');
|
|
});
|
|
|
|
test('should handle (legacy) colon separated argument as only one, even with more separators (double colon)', async ({ page }) => {
|
|
const input = 'Reversed: {{reverse:abc::def}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Reversed: fed::cba!');
|
|
});
|
|
|
|
test('should handle (legacy) colon separated argument as only one, even with more separators (single colon)', async ({ page }) => {
|
|
const input = 'Reversed: {{reverse:abc:def}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Reversed: fed:cba!');
|
|
});
|
|
|
|
test('should handle (legacy) whitespace separated unnamed argument', async ({ page }) => {
|
|
const input = 'Values: {{roll 1d1}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Values: 1!');
|
|
});
|
|
|
|
test('should handle (legacy) whitespace separated unnamed argument as only one, even with more separators (space)', async ({ page }) => {
|
|
const input = 'Values: {{reverse abc def}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Values: fed cba!');
|
|
});
|
|
|
|
test('should support multi-line arguments for macros', async ({ page }) => {
|
|
const input = 'Result: {{reverse::first line\nsecond line}}'; // "\n" becomes a real newline in the macro argument
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
const original = 'first line\nsecond line';
|
|
const expectedReversed = Array.from(original).reverse().join('');
|
|
expect(output).toBe(`Result: ${expectedReversed}`);
|
|
});
|
|
});
|
|
|
|
test.describe('Nested macros', () => {
|
|
test('should resolve nested macros inside arguments inside-out', async ({ page }) => {
|
|
const input = 'Result: {{setvar::test::0}}{{reverse::{{addvar::test::100}}{{getvar::test}}}}{{setvar::test::0}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Result: 001');
|
|
});
|
|
|
|
// {{wrap::{{upper::x}}::[::]}} -> '[X]'
|
|
test('should resolve nested macros across multiple arguments', async ({ page }) => {
|
|
const input = 'Result: {{setvar::addvname::test}}{{addvar::{{getvar::addvname}}::{{setvar::test::5}}{{getvar::test}}}}{{getvar::test}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Result: 10');
|
|
});
|
|
});
|
|
|
|
test.describe('Unknown macros', () => {
|
|
test('should keep unknown macro syntax but resolve nested macros inside it', async ({ page }) => {
|
|
const input = 'Test: {{unknown::{{newline}}}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Test: {{unknown::\n}}');
|
|
});
|
|
|
|
test('should keep surrounding text inside unknown macros intact', async ({ page }) => {
|
|
const input = 'Test: {{unknown::my {{newline}} example}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Test: {{unknown::my \n example}}');
|
|
});
|
|
});
|
|
|
|
test.describe('Comment macro', () => {
|
|
test('should remove single-line comments with simple body', async ({ page }) => {
|
|
const input = 'Hello{{// comment}}World';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('HelloWorld');
|
|
});
|
|
|
|
test('should accept non-word characters immediately after //', async ({ page }) => {
|
|
const input = 'A{{//!@#$%^&*()_+}}B';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('AB');
|
|
});
|
|
|
|
test('should ignore additional // sequences inside the comment body', async ({ page }) => {
|
|
const input = 'X{{//comment with // extra // slashes}}Y';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('XY');
|
|
});
|
|
|
|
test('should support multi-line comment bodies', async ({ page }) => {
|
|
const input = 'Start{{// line one\nline two\nline three}}End';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('StartEnd');
|
|
});
|
|
});
|
|
|
|
test.describe('Trim macro', () => {
|
|
test('should trim content inside scoped trim macro', async ({ page }) => {
|
|
const input = '{{trim}} hello world {{/trim}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('hello world');
|
|
});
|
|
|
|
test('should trim leading whitespace in scoped trim', async ({ page }) => {
|
|
const input = '{{trim}}\n\n content{{/trim}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('content');
|
|
});
|
|
|
|
test('should trim trailing whitespace in scoped trim', async ({ page }) => {
|
|
const input = '{{trim}}content \n\n{{/trim}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('content');
|
|
});
|
|
|
|
test('should handle scoped trim with macros inside', async ({ page }) => {
|
|
const input = '{{trim}} Hello {{user}} {{/trim}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User');
|
|
});
|
|
|
|
test('should handle nested scoped trim', async ({ page }) => {
|
|
const input = '{{trim}} outer {{trim}} inner {{/trim}} outer {{/trim}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('outer inner outer');
|
|
});
|
|
});
|
|
|
|
test.describe('Legacy compatibility', () => {
|
|
test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
|
|
const input = 'foo\n\n{{trim}}\n\nbar';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('foobar');
|
|
});
|
|
|
|
test('should handle multiple trim macros in a single string', async ({ page }) => {
|
|
const input = 'A\n\n{{trim}}\n\nB\n\n{{trim}}\n\nC';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('ABC');
|
|
});
|
|
|
|
test('should support legacy time macro with positive offset via pre-processing', async ({ page }) => {
|
|
const input = 'Time: {{time_UTC+2}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// After pre-processing, this should behave like {{time::UTC+2}} and be resolved by the time macro.
|
|
// We only assert that the placeholder was consumed and some non-empty value was produced.
|
|
expect(output).not.toBe(input);
|
|
expect(output.startsWith('Time: ')).toBeTruthy();
|
|
expect(output.length).toBeGreaterThan('Time: '.length);
|
|
});
|
|
|
|
test('should support legacy time macro with negative offset via pre-processing', async ({ page }) => {
|
|
const input = 'Time: {{time_UTC-10}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
expect(output).not.toBe(input);
|
|
expect(output.startsWith('Time: ')).toBeTruthy();
|
|
expect(output.length).toBeGreaterThan('Time: '.length);
|
|
});
|
|
|
|
test('should support legacy <USER> marker via pre-processing', async ({ page }) => {
|
|
const input = 'Hello <USER>!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// In the default test env, name1Override is "User".
|
|
expect(output).toBe('Hello User!');
|
|
});
|
|
|
|
test('should support legacy <BOT> and <CHAR> markers via pre-processing', async ({ page }) => {
|
|
const input = 'Bot: <BOT>, Char: <CHAR>.';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// In the default test env, name2Override is "Character".
|
|
expect(output).toBe('Bot: Character, Char: Character.');
|
|
});
|
|
|
|
test('should support legacy <GROUP> and <CHARIFNOTGROUP> markers via pre-processing (non-group fallback)', async ({ page }) => {
|
|
const input = 'Group: <GROUP>, CharIfNotGroup: <CHARIFNOTGROUP>.';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// Without an active group, both markers fall back to the current character name.
|
|
expect(output).toBe('Group: Character, CharIfNotGroup: Character.');
|
|
});
|
|
});
|
|
|
|
test.describe('Bracket handling around macros', () => {
|
|
test('should allow single opening brace inside macro arguments', async ({ page }) => {
|
|
const input = 'Test§ {{reverse::my { test}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
// "my { test" reversed becomes "tset { ym"
|
|
expect(output).toBe('Test§ tset { ym');
|
|
|
|
const EXPECT_WARNINGS = false;
|
|
const EXPECT_ERRORS = false;
|
|
expect(hasMacroWarnings).toBe(EXPECT_WARNINGS);
|
|
expect(hasMacroErrors).toBe(EXPECT_ERRORS);
|
|
});
|
|
|
|
test('should allow single closing brace inside macro arguments', async ({ page }) => {
|
|
const input = 'Test§ {{reverse::my } test}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
// "my } test" reversed becomes "tset } ym"
|
|
expect(output).toBe('Test§ tset } ym');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should treat unterminated macro with identifier at end of input as plain text', async ({ page }) => {
|
|
const input = 'Test {{ hehe';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe(input);
|
|
|
|
expect(hasMacroWarnings).toBe(true);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should treat invalid macro start as plain text when followed by non-identifier characters', async ({ page }) => {
|
|
const input = 'Test {{§§ hehe';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe(input);
|
|
|
|
expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should treat unterminated macro in the middle of the string as plain text', async ({ page }) => {
|
|
const input = 'Before {{ hehe After';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe(input);
|
|
|
|
expect(hasMacroWarnings).toBe(true);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should treat dangling macro start as text and still evaluate subsequent macro', async ({ page }) => {
|
|
const input = 'Test {{ hehe {{user}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
// Default test env uses name1Override = "User" and name2Override = "Character".
|
|
expect(output).toBe('Test {{ hehe User');
|
|
|
|
expect(hasMacroWarnings).toBe(true);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should ignore invalid macro start but still evaluate following valid macro', async ({ page }) => {
|
|
const input = 'Test {{&& hehe {{user}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
// Default test env uses name1Override = "User" and name2Override = "Character".
|
|
expect(output).toBe('Test {{&& hehe User');
|
|
|
|
expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should allow single opening brace immediately before a macro', async ({ page }) => {
|
|
const input = '{{{char}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
// One literal '{' plus the resolved character name.
|
|
expect(output).toBe('{Character');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should allow single closing brace immediately after a macro', async ({ page }) => {
|
|
const input = '{{char}}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('Character}');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should allow single braces around a macro', async ({ page }) => {
|
|
const input = '{{{char}}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('{Character}');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should allow double opening braces immediately before a macro', async ({ page }) => {
|
|
const input = '{{{{char}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('{{Character');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should allow double closing braces immediately after a macro', async ({ page }) => {
|
|
const input = '{{char}}}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('Character}}');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should allow double braces around a macro', async ({ page }) => {
|
|
const input = '{{{{char}}}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('{{Character}}');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should resolve nested macro inside argument with surrounding braces', async ({ page }) => {
|
|
const input = 'Result: {{reverse::pre-{ {{user}} }-post}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
// Argument "pre-{ User }-post" reversed becomes "tsop-} resU {-erp".
|
|
expect(output).toBe('Result: tsop-} resU {-erp');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should handle adjacent macros with no separator', async ({ page }) => {
|
|
const input = '{{char}}{{user}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('CharacterUser');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should handle macros separated only by surrounding braces', async ({ page }) => {
|
|
const input = '{{char}}{ {{user}} }';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('Character{ User }');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should handle Windows newlines with braces near macros', async ({ page }) => {
|
|
const input = 'Line1 {{char}}\r\n{Line2}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('Line1 Character\r\n{Line2}');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should treat stray closing braces outside macros as plain text', async ({ page }) => {
|
|
const input = 'Foo }} bar';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe(input);
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should keep stray closing braces and still evaluate following macro', async ({ page }) => {
|
|
const input = 'Foo }} {{user}}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('Foo }} User');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
|
|
test('should handle stray closing braces before macros as plain text', async ({ page }) => {
|
|
const input = 'Foo {{user}} }}';
|
|
const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
|
|
|
|
expect(output).toBe('Foo User }}');
|
|
|
|
expect(hasMacroWarnings).toBe(false);
|
|
expect(hasMacroErrors).toBe(false);
|
|
});
|
|
});
|
|
|
|
test.describe('Arity errors', () => {
|
|
test('should not resolve macro without arguments when called with arguments', async ({ page }) => {
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
const input = 'Start {{char::extra}} end.';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// Macro text should remain unchanged
|
|
expect(output).toBe(input);
|
|
|
|
// Should have logged an arity warning for char
|
|
expect(warnings.some(w => w.includes('Macro "char"') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
|
|
test('should not resolve reverse when called without arguments', async ({ page }) => {
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
const input = 'Result: {{reverse}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
expect(output).toBe(input);
|
|
|
|
expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
|
|
test('should not resolve reverse when called with too many arguments', async ({ page }) => {
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
const input = 'Result: {{reverse::a::b}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// Macro text should remain unchanged when extra unnamed args are provided
|
|
expect(output).toBe(input);
|
|
|
|
// Should have logged an arity warning for reverse
|
|
expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
|
|
test('should not resolve list-bounded macro when called outside list bounds', async ({ page }) => {
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
// Register a temporary macro with explicit list bounds: exactly 1 required + 1-2 list args
|
|
await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-list-bounds');
|
|
MacroRegistry.registerMacro('test-list-bounds', {
|
|
unnamedArgs: 1,
|
|
list: { min: 1, max: 2 },
|
|
description: 'Test macro for list bounds.',
|
|
handler: ({ unnamedArgs, list }) => {
|
|
const all = [...unnamedArgs, ...(list ?? [])];
|
|
return all.join('|');
|
|
},
|
|
});
|
|
});
|
|
|
|
// First macro: too few list args (only required arg)
|
|
// Second macro: too many list args (required arg + 3 list entries)
|
|
const input = 'A {{test-list-bounds::base}} B {{test-list-bounds::base::x::y::z}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// Both macros should remain unchanged in the output
|
|
expect(output).toBe(input);
|
|
|
|
const testWarnings = warnings.filter(w => w.includes('Macro "test-list-bounds"') && w.includes('unnamed arguments'));
|
|
// We expect one warning for each invalid invocation (too few and too many list args)
|
|
expect(testWarnings.length).toBe(2);
|
|
});
|
|
|
|
test('should resolve nested macros in arguments, even though the outer macro has wrong number of arguments', async ({ page }) => {
|
|
// Macro {{user ....}} will fail, because it has no args, but {{char}} should still resolve
|
|
const input = 'Result: {{user Something {{char}}}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Result: {{user Something Character}}');
|
|
});
|
|
|
|
});
|
|
|
|
test.describe('Type validation', () => {
|
|
test('should not resolve strict typed macro when argument type is invalid', async ({ page }) => {
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-int-strict');
|
|
MacroRegistry.registerMacro('test-int-strict', {
|
|
unnamedArgs: [
|
|
{ name: 'value', type: 'integer', description: 'Must be an integer.' },
|
|
],
|
|
strictArgs: true,
|
|
description: 'Strict integer macro for testing type validation.',
|
|
handler: ({ unnamedArgs: [value] }) => `#${value}#`,
|
|
});
|
|
});
|
|
|
|
const input = 'Value: {{test-int-strict::abc}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// Strict typed macro should leave the text unchanged when the argument is invalid
|
|
expect(output).toBe(input);
|
|
|
|
// A runtime type validation warning should be logged
|
|
expect(warnings.some(w => w.includes('Macro "test-int-strict"') && w.includes('expected type integer'))).toBeTruthy();
|
|
});
|
|
|
|
test('should resolve non-strict typed macro when argument type is invalid but still log warning', async ({ page }) => {
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') {
|
|
warnings.push(msg.text());
|
|
}
|
|
});
|
|
|
|
await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-int-nonstrict');
|
|
MacroRegistry.registerMacro('test-int-nonstrict', {
|
|
unnamedArgs: [
|
|
{ name: 'value', type: 'integer', description: 'Must be an integer.' },
|
|
],
|
|
strictArgs: false,
|
|
description: 'Non-strict integer macro for testing type validation.',
|
|
handler: ({ unnamedArgs: [value] }) => `#${value}#`,
|
|
});
|
|
});
|
|
|
|
const input = 'Value: {{test-int-nonstrict::abc}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
|
|
// Non-strict typed macro should still execute, even with invalid type
|
|
expect(output).toBe('Value: #abc#');
|
|
|
|
// A runtime type validation warning should still be logged
|
|
expect(warnings.some(w => w.includes('Macro "test-int-nonstrict"') && w.includes('expected type integer'))).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Environment', () => {
|
|
test('should expose original content as env.content to macro handlers', async ({ page }) => {
|
|
const input = '{{env-content}}';
|
|
const originalContent = 'This is the full original input string.';
|
|
|
|
const output = await page.evaluate(async ({ input, originalContent }) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('env-content');
|
|
MacroRegistry.registerMacro('env-content', {
|
|
description: 'Test macro that returns env.content.',
|
|
handler: ({ env }) => env.content,
|
|
});
|
|
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
|
|
const rawEnv = {
|
|
content: originalContent,
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
return MacroEngine.evaluate(input, env);
|
|
}, { input, originalContent });
|
|
|
|
expect(output).toBe(originalContent);
|
|
});
|
|
});
|
|
|
|
test.describe('Deterministic pick macro', () => {
|
|
/** Fixed chat ID hash used across all pick tests for deterministic behavior */
|
|
const TEST_CHAT_ID_HASH = 123456;
|
|
|
|
/**
|
|
* Registers a testable pick macro that returns the seed string instead of the picked value.
|
|
* This allows tests to verify that different macro positions produce different seeds.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
*/
|
|
async function registerTestablePick(page) {
|
|
await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry, MacroCategory } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
/** @type {import('../../public/scripts/utils.js')} */
|
|
const { getStringHash } = await import('./scripts/utils.js');
|
|
/** @type {import('../../public/script.js')} */
|
|
const { chat_metadata } = await import('./script.js');
|
|
/** @type {import('../../public/lib.js')} */
|
|
const { seedrandom } = await import('./lib.js');
|
|
|
|
// Only register once
|
|
if (MacroRegistry.getMacro('testablePick')) return;
|
|
|
|
MacroRegistry.registerMacro('testablePick', {
|
|
category: MacroCategory.RANDOM,
|
|
list: true,
|
|
description: 'Test version of pick that returns the seed string for verification.',
|
|
handler: ({ list, globalOffset, env }) => {
|
|
const chatIdHash = chat_metadata.chat_id_hash ?? 0;
|
|
const rawContentHash = env.contentHash;
|
|
const offset = globalOffset;
|
|
const rerollSeed = chat_metadata.pick_reroll_seed || null;
|
|
const combinedSeedString = [chatIdHash, rawContentHash, offset, rerollSeed].filter(it => it !== null).join('-');
|
|
// Return both the seed and what would be picked for validation
|
|
const finalSeed = getStringHash(combinedSeedString);
|
|
const rng = seedrandom(String(finalSeed));
|
|
const randomIndex = Math.floor(rng() * list.length);
|
|
return `seed:${combinedSeedString}|pick:${list[randomIndex]}`;
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
// Set consistent chat ID hash for all tests
|
|
await page.evaluate(async (hash) => {
|
|
/** @type {import('../../public/script.js')} */
|
|
const { chat_metadata } = await import('./script.js');
|
|
chat_metadata.chat_id_hash = hash;
|
|
}, TEST_CHAT_ID_HASH);
|
|
});
|
|
|
|
test('should return stable results for the same chat and content', async ({ page }) => {
|
|
const input = 'Choices: {{pick::red::green::blue}}, {{pick::red::green::blue}}.';
|
|
|
|
const output1 = await evaluateWithEngine(page, input);
|
|
const output2 = await evaluateWithEngine(page, input);
|
|
|
|
// Deterministic: same chat and same content should yield identical output
|
|
expect(output1).toBe(output2);
|
|
|
|
// Sanity check: both picks should resolve to one of the provided options
|
|
const match = output1.match(/Choices: ([^,]+), ([^.]+)\./);
|
|
expect(match).not.toBeNull();
|
|
if (!match) return;
|
|
|
|
const first = match[1].trim();
|
|
const second = match[2].trim();
|
|
const options = ['red', 'green', 'blue'];
|
|
|
|
expect(options.includes(first)).toBeTruthy();
|
|
expect(options.includes(second)).toBeTruthy();
|
|
});
|
|
|
|
test('should use different seeds for identical picks at different positions', async ({ page }) => {
|
|
await registerTestablePick(page);
|
|
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const input = '{{testablePick::A::B::C}}###{{testablePick::A::B::C}}';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
return MacroEngine.evaluate(input, env);
|
|
});
|
|
|
|
const parts = output.split('###');
|
|
expect(parts.length).toBe(2);
|
|
|
|
// Extract seeds from both results
|
|
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
|
|
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
|
|
|
|
expect(seed1).toBeTruthy();
|
|
expect(seed2).toBeTruthy();
|
|
// Seeds must be different because the macros are at different positions
|
|
expect(seed1).not.toBe(seed2);
|
|
|
|
// Verify picked values are valid options
|
|
const pick1 = parts[0].match(/pick:(\w+)/)?.[1];
|
|
const pick2 = parts[1].match(/pick:(\w+)/)?.[1];
|
|
const options = ['A', 'B', 'C'];
|
|
expect(options.includes(pick1 ?? '')).toBeTruthy();
|
|
expect(options.includes(pick2 ?? '')).toBeTruthy();
|
|
});
|
|
|
|
test('should use different seeds for identical picks inside different scoped macros at the same offset', async ({ page }) => {
|
|
await registerTestablePick(page);
|
|
|
|
// Key regression test: picks inside scoped content must use global offsets
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Two identical pick macros inside different setvar scopes
|
|
// Before the fix, both would get startOffset=0 relative to their argument
|
|
// After the fix, they get different globalOffset values
|
|
const input = '{{setvar::first}}{{testablePick::A::B::C}}{{/setvar}}{{setvar::second}}{{testablePick::A::B::C}}{{/setvar}}{{.first}}###{{.second}}';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
return MacroEngine.evaluate(input, env);
|
|
});
|
|
|
|
const parts = output.split('###');
|
|
expect(parts.length).toBe(2);
|
|
|
|
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
|
|
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
|
|
|
|
expect(seed1).toBeTruthy();
|
|
expect(seed2).toBeTruthy();
|
|
// Seeds must be different - this is the key assertion for the fix
|
|
expect(seed1).not.toBe(seed2);
|
|
});
|
|
|
|
test('should use different seeds for identical picks in inline arguments', async ({ page }) => {
|
|
await registerTestablePick(page);
|
|
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Two identical pick macros inside different setvar inline arguments
|
|
const input = '{{setvar::first::{{testablePick::A::B::C}}}}{{setvar::second::{{testablePick::A::B::C}}}}{{.first}}###{{.second}}';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
return MacroEngine.evaluate(input, env);
|
|
});
|
|
|
|
const parts = output.split('###');
|
|
expect(parts.length).toBe(2);
|
|
|
|
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
|
|
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
|
|
|
|
expect(seed1).toBeTruthy();
|
|
expect(seed2).toBeTruthy();
|
|
// Seeds must be different due to different global offsets
|
|
expect(seed1).not.toBe(seed2);
|
|
});
|
|
|
|
test('should maintain stability across evaluations for picks in scoped content', async ({ page }) => {
|
|
// Picks inside scoped content should still be deterministic (same result each time)
|
|
const outputs = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const input = '{{setvar::val}}{{pick::X::Y::Z}}{{/setvar}}{{.val}}';
|
|
const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
const result1 = MacroEngine.evaluate(input, env1);
|
|
const result2 = MacroEngine.evaluate(input, env2);
|
|
return [result1, result2];
|
|
});
|
|
|
|
// Same input should produce same output (deterministic)
|
|
expect(outputs[0]).toBe(outputs[1]);
|
|
// Should be one of the valid options
|
|
expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
|
|
});
|
|
|
|
test('should use different seeds for identical picks inside different if blocks (delayArgResolution)', async ({ page }) => {
|
|
await registerTestablePick(page);
|
|
|
|
// Key regression test: picks inside {{if}} blocks use resolve() which must preserve globalOffset
|
|
// This tests the fix for macros with delayArgResolution that call resolve() internally
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Two identical pick macros inside different if blocks
|
|
// Before the fix, both would get contextOffset=0 when resolve() was called
|
|
// After the fix, resolve() passes the caller's globalOffset as contextOffset
|
|
const input = '{{if true}}{{testablePick::A::B::C}}{{/if}}###{{if true}}{{testablePick::A::B::C}}{{/if}}';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
return MacroEngine.evaluate(input, env);
|
|
});
|
|
|
|
const parts = output.split('###');
|
|
expect(parts.length).toBe(2);
|
|
|
|
const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
|
|
const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
|
|
|
|
expect(seed1).toBeTruthy();
|
|
expect(seed2).toBeTruthy();
|
|
// Seeds must be different because the {{if}} blocks are at different positions
|
|
expect(seed1).not.toBe(seed2);
|
|
});
|
|
|
|
test('should maintain stability for picks inside if blocks across evaluations', async ({ page }) => {
|
|
// Picks inside if blocks should still be deterministic
|
|
const outputs = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const input = '{{if true}}{{pick::X::Y::Z}}{{/if}}';
|
|
const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
const result1 = MacroEngine.evaluate(input, env1);
|
|
const result2 = MacroEngine.evaluate(input, env2);
|
|
return [result1, result2];
|
|
});
|
|
|
|
// Same input should produce same output (deterministic)
|
|
expect(outputs[0]).toBe(outputs[1]);
|
|
// Should be one of the valid options
|
|
expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Dynamic macros', () => {
|
|
test.describe('String value dynamic macros', () => {
|
|
test('should resolve dynamic macro with string value', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: 'Test: {{myvalue}}',
|
|
dynamicMacros: {
|
|
myvalue: 'hello world',
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('Test: {{myvalue}}', env);
|
|
});
|
|
|
|
expect(output).toBe('Test: hello world');
|
|
});
|
|
|
|
test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
num: 42,
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('Value: {{num}}', env);
|
|
});
|
|
|
|
expect(output).toBe('Value: 42');
|
|
});
|
|
|
|
test('should not resolve string dynamic macro when called with arguments', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const input = 'Dyn: {{myvalue::extra}}';
|
|
const output = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: input,
|
|
dynamicMacros: { myvalue: 'hello' },
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate(input, env);
|
|
}, input);
|
|
|
|
expect(output).toBe(input);
|
|
expect(warnings.some(w => w.includes('Macro "myvalue"') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Handler function dynamic macros', () => {
|
|
test('should resolve dynamic macro with handler function', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
dyn: () => 'handler result',
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('Result: {{dyn}}', env);
|
|
});
|
|
|
|
expect(output).toBe('Result: handler result');
|
|
});
|
|
|
|
test('should pass execution context to handler function', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: 'full content here',
|
|
dynamicMacros: {
|
|
dyn: (ctx) => `name=${ctx.name}, content=${ctx.env.content}`,
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{dyn}}', env);
|
|
});
|
|
|
|
expect(output).toBe('name=dyn, content=full content here');
|
|
});
|
|
|
|
test('should not resolve handler dynamic macro when called with arguments due to strict arity', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const input = 'Dyn: {{dyn::extra}}';
|
|
const output = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: input,
|
|
dynamicMacros: {
|
|
dyn: () => 'OK',
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate(input, env);
|
|
}, input);
|
|
|
|
expect(output).toBe(input);
|
|
expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('MacroDefinitionOptions dynamic macros', () => {
|
|
test('should resolve dynamic macro with MacroDefinitionOptions', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
greet: {
|
|
description: 'A greeting macro',
|
|
handler: () => 'Hello from options!',
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{greet}}', env);
|
|
});
|
|
|
|
expect(output).toBe('Hello from options!');
|
|
});
|
|
|
|
test('should support unnamed arguments in dynamic macro with options', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
greet: {
|
|
unnamedArgs: [{ name: 'name' }],
|
|
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{greet::World}}', env);
|
|
});
|
|
|
|
expect(output).toBe('Hello, World!');
|
|
});
|
|
|
|
test('should support multiple unnamed arguments in dynamic macro', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
wrap: {
|
|
unnamedArgs: [
|
|
{ name: 'content' },
|
|
{ name: 'prefix' },
|
|
{ name: 'suffix' },
|
|
],
|
|
handler: ({ unnamedArgs: [content, prefix, suffix] }) => `${prefix}${content}${suffix}`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{wrap::hello::[::]}}', env);
|
|
});
|
|
|
|
expect(output).toBe('[hello]');
|
|
});
|
|
|
|
test('should support optional arguments in dynamic macro', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
greet: {
|
|
unnamedArgs: [
|
|
{ name: 'name' },
|
|
{ name: 'greeting', optional: true, defaultValue: 'Hello' },
|
|
],
|
|
handler: ({ unnamedArgs: [name, greeting] }) => `${greeting || 'Hello'}, ${name}!`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
const result1 = MacroEngine.evaluate('{{greet::World}}', env);
|
|
const result2 = MacroEngine.evaluate('{{greet::World::Hi}}', env);
|
|
return { result1, result2 };
|
|
});
|
|
|
|
expect(output.result1).toBe('Hello, World!');
|
|
expect(output.result2).toBe('Hi, World!');
|
|
});
|
|
|
|
test('should support list arguments in dynamic macro', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
join: {
|
|
unnamedArgs: [{ name: 'separator' }],
|
|
list: true,
|
|
handler: ({ unnamedArgs: [sep], list }) => list.join(sep),
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{join::-::a::b::c}}', env);
|
|
});
|
|
|
|
expect(output).toBe('a-b-c');
|
|
});
|
|
|
|
test('should enforce type validation in dynamic macro with options', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const input = '{{calc::abc}}';
|
|
const output = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: input,
|
|
dynamicMacros: {
|
|
calc: {
|
|
unnamedArgs: [{ name: 'value', type: 'integer' }],
|
|
strictArgs: true,
|
|
handler: ({ unnamedArgs: [val] }) => `#${val}#`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate(input, env);
|
|
}, input);
|
|
|
|
expect(output).toBe(input);
|
|
expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
|
|
});
|
|
|
|
test('should respect strictArgs: false in dynamic macro with options', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
calc: {
|
|
unnamedArgs: [{ name: 'value', type: 'integer' }],
|
|
strictArgs: false,
|
|
handler: ({ unnamedArgs: [val] }) => `#${val}#`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{calc::abc}}', env);
|
|
});
|
|
|
|
expect(output).toBe('#abc#');
|
|
expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
|
|
});
|
|
|
|
test('should fail arity check in dynamic macro with options when too few args', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const input = '{{greet}}';
|
|
const output = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: input,
|
|
dynamicMacros: {
|
|
greet: {
|
|
unnamedArgs: [{ name: 'name' }],
|
|
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate(input, env);
|
|
}, input);
|
|
|
|
expect(output).toBe(input);
|
|
expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
|
|
test('should fail arity check in dynamic macro with options when too many args', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const input = '{{greet::one::two}}';
|
|
const output = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: input,
|
|
dynamicMacros: {
|
|
greet: {
|
|
unnamedArgs: [{ name: 'name' }],
|
|
handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate(input, env);
|
|
}, input);
|
|
|
|
expect(output).toBe(input);
|
|
expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
|
|
});
|
|
|
|
test('should handle invalid MacroDefinitionOptions gracefully', async ({ page }) => {
|
|
const warnings = [];
|
|
page.on('console', msg => {
|
|
if (msg.type() === 'warning') warnings.push(msg.text());
|
|
});
|
|
|
|
const input = '{{bad}}';
|
|
const output = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
|
|
const rawEnv = {
|
|
content: input,
|
|
dynamicMacros: {
|
|
bad: {
|
|
// Missing handler - should fail validation
|
|
unnamedArgs: 1,
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate(input, env);
|
|
}, input);
|
|
|
|
// Should remain unresolved since options are invalid
|
|
expect(output).toBe(input);
|
|
expect(warnings.some(w => w.includes('bad') && w.includes('is not defined correctly'))).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Dynamic macro priority and case sensitivity', () => {
|
|
test('should override registered macro with dynamic macro of same name', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
name1Override: 'User',
|
|
dynamicMacros: {
|
|
user: 'DynamicUser',
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{user}}', env);
|
|
});
|
|
|
|
expect(output).toBe('DynamicUser');
|
|
});
|
|
|
|
test('should match dynamic macro names case-insensitively', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
MyMacro: 'value',
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
const r1 = MacroEngine.evaluate('{{MyMacro}}', env);
|
|
const r2 = MacroEngine.evaluate('{{mymacro}}', env);
|
|
const r3 = MacroEngine.evaluate('{{MYMACRO}}', env);
|
|
return { r1, r2, r3 };
|
|
});
|
|
|
|
expect(output.r1).toBe('value');
|
|
expect(output.r2).toBe('value');
|
|
expect(output.r3).toBe('value');
|
|
});
|
|
|
|
test('should resolve multiple different dynamic macros in same evaluation', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const rawEnv = {
|
|
content: '',
|
|
dynamicMacros: {
|
|
a: 'alpha',
|
|
b: () => 'beta',
|
|
c: {
|
|
handler: () => 'gamma',
|
|
},
|
|
},
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{a}}-{{b}}-{{c}}', env);
|
|
});
|
|
|
|
expect(output).toBe('alpha-beta-gamma');
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Macro flags', () => {
|
|
test('should resolve macro with legacy hash flag (no effect)', async ({ page }) => {
|
|
// Legacy hash flag should be parsed but have no effect
|
|
const input = 'Hello {{#user}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User!');
|
|
});
|
|
|
|
test('should keep unmatched closing block macro as raw text', async ({ page }) => {
|
|
// Closing block without matching opening should be kept as raw
|
|
const input = '{{/unknown}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{/unknown}}');
|
|
});
|
|
|
|
test('should keep unmatched closing block macro for existing macro as raw text', async ({ page }) => {
|
|
// Closing block for a known macro (user) without matching opening should stay raw
|
|
const input = '{{/user}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{/user}}');
|
|
});
|
|
|
|
test('should keep unmatched closing block macro with arguments as raw text', async ({ page }) => {
|
|
// Closing block with arguments should stay raw (closing macros don't take args anyway)
|
|
const input = '{{/getvar::test}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{/getvar::test}}');
|
|
});
|
|
|
|
test('should keep closing macro raw when surrounded by other content', async ({ page }) => {
|
|
// Closing macro in middle of text should stay raw, other macros should resolve
|
|
const input = 'Hello {{user}}, this {{/char}} is raw, bye {{char}}!';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User, this {{/char}} is raw, bye Character!');
|
|
});
|
|
|
|
test('should resolve scoped macro while keeping unrelated closing raw', async ({ page }) => {
|
|
// Scoped macro resolves normally, unrelated closing stays raw
|
|
const input = '{{setvar::x}}value{{/setvar}}{{/user}}{{getvar::x}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{/user}}value');
|
|
});
|
|
|
|
test('should pass flags to macro handler', async ({ page }) => {
|
|
// Register a test macro that returns its flags
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-flags');
|
|
MacroRegistry.registerMacro('test-flags', {
|
|
description: 'Test macro that returns its flags.',
|
|
handler: ({ flags }) => {
|
|
const activeFlags = flags.raw.join(',') || 'none';
|
|
return `[${activeFlags}]`;
|
|
},
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
return MacroEngine.evaluate('{{test-flags}} / {{!test-flags}} / {{!?test-flags}}', env);
|
|
});
|
|
|
|
expect(output).toBe('[none] / [!] / [!,?]');
|
|
});
|
|
|
|
test('should correctly identify individual flags in handler', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-flag-check');
|
|
MacroRegistry.registerMacro('test-flag-check', {
|
|
description: 'Test macro that checks specific flags.',
|
|
handler: ({ flags }) => {
|
|
const parts = [];
|
|
if (flags.immediate) parts.push('immediate');
|
|
if (flags.delayed) parts.push('delayed');
|
|
if (flags.filter) parts.push('filter');
|
|
if (flags.closingBlock) parts.push('closingBlock');
|
|
if (flags.preserveWhitespace) parts.push('preserveWhitespace');
|
|
return parts.join('+') || 'noflags';
|
|
},
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
const results = [
|
|
MacroEngine.evaluate('{{test-flag-check}}', env),
|
|
MacroEngine.evaluate('{{!test-flag-check}}', env),
|
|
MacroEngine.evaluate('{{?test-flag-check}}', env),
|
|
MacroEngine.evaluate('{{>test-flag-check}}', env),
|
|
// Note: {{/test-flag-check}} would stay raw (unmatched closing macro)
|
|
MacroEngine.evaluate('{{#test-flag-check}}', env),
|
|
MacroEngine.evaluate('{{!?>test-flag-check}}', env),
|
|
];
|
|
return results.join(' | ');
|
|
});
|
|
|
|
// Closing flag (/) is not tested here as standalone closing macros stay raw
|
|
expect(output).toBe('noflags | immediate | delayed | filter | preserveWhitespace | immediate+delayed+filter');
|
|
});
|
|
|
|
test('should handle flags with arguments correctly', async ({ page }) => {
|
|
const input = '{{!reverse::hello}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
// The flag should not affect the macro resolution
|
|
expect(output).toBe('olleh');
|
|
});
|
|
|
|
test('should handle multiple flags with whitespace', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-flags-ws');
|
|
MacroRegistry.registerMacro('test-flags-ws', {
|
|
description: 'Test macro for flags with whitespace.',
|
|
handler: ({ flags }) => flags.raw.length.toString(),
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
return MacroEngine.evaluate('{{ ! ? > test-flags-ws }}', env);
|
|
});
|
|
|
|
expect(output).toBe('3');
|
|
});
|
|
});
|
|
|
|
test.describe('Scoped macros', () => {
|
|
test('should merge scoped content as last unnamed argument', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}Hello World{{/setvar}}{{getvar::myvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello World');
|
|
});
|
|
|
|
test('should be equivalent to inline argument syntax', async ({ page }) => {
|
|
const input1 = '{{setvar::myvar::test value}}{{getvar::myvar}}';
|
|
const input2 = '{{setvar::myvar}}test value{{/setvar}}{{getvar::myvar}}';
|
|
|
|
const output1 = await evaluateWithEngine(page, input1);
|
|
const output2 = await evaluateWithEngine(page, input2);
|
|
|
|
expect(output1).toBe(output2);
|
|
});
|
|
|
|
test('should resolve nested macros inside scoped content', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}Hello {{user}}!{{/setvar}}{{getvar::myvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User!');
|
|
});
|
|
|
|
test('should handle nested scoped macros with same name', async ({ page }) => {
|
|
// Outer scope sets 'outer', inner scope sets 'inner'
|
|
// Since setvar returns '', the inner macro contributes nothing to outer's content
|
|
const input = '{{setvar::outer}}before {{setvar::inner}}nested{{/setvar}} after{{/setvar}}{{getvar::outer}} | {{getvar::inner}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('before after | nested'); // Note: double space where inner setvar was
|
|
});
|
|
|
|
test('should handle multiple independent scoped macros', async ({ page }) => {
|
|
const input = '{{setvar::a}}first{{/setvar}}{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[first][second]');
|
|
});
|
|
|
|
test('should keep unmatched closing tag as raw text', async ({ page }) => {
|
|
const input = 'Before {{/setvar}} After';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Before {{/setvar}} After');
|
|
});
|
|
|
|
test('should keep second closing tag as raw when already closed', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}content{{/setvar}}{{/setvar}}{{getvar::myvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{/setvar}}content');
|
|
});
|
|
|
|
test('should work with empty scoped content', async ({ page }) => {
|
|
const input = '{{setvar::empty}}{{/setvar}}[{{getvar::empty}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[]');
|
|
});
|
|
|
|
test('should work with multi-line scoped content', async ({ page }) => {
|
|
const input = '{{setvar::multi}}Line 1\nLine 2\nLine 3{{/setvar}}{{getvar::multi}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Line 1\nLine 2\nLine 3');
|
|
});
|
|
|
|
test('should preserve plaintext around scoped macros', async ({ page }) => {
|
|
const input = 'Before {{setvar::x}}value{{/setvar}} After {{getvar::x}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Before After value');
|
|
});
|
|
|
|
test('should handle deeply nested scoped macros', async ({ page }) => {
|
|
// Since setvar returns '', nested setvars contribute nothing to parent content
|
|
// l3 = "C", l2 = "B" + "" + "B" = "BB", l1 = "A" + "" + "A" = "AA"
|
|
const input = '{{setvar::l1}}A{{setvar::l2}}B{{setvar::l3}}C{{/setvar}}B{{/setvar}}A{{/setvar}}{{getvar::l1}}|{{getvar::l2}}|{{getvar::l3}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('AA|BB|C');
|
|
});
|
|
|
|
test('should handle scoped macro with existing arguments', async ({ page }) => {
|
|
// reverse takes 1 arg; scoped content becomes the only arg
|
|
const input = '{{reverse}}hello{{/reverse}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('olleh');
|
|
});
|
|
|
|
test('should not match closing tag for different macro name', async ({ page }) => {
|
|
// Opening setvar, closing getvar - should not match
|
|
const input = '{{setvar::x}}content{{/getvar}}{{getvar::x}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
// setvar without proper closing keeps looking, finds none, so it stays as is
|
|
// getvar closing has no opener, stays as raw
|
|
expect(output).toBe('{{setvar::x}}content{{/getvar}}');
|
|
});
|
|
|
|
test('should handle scoped content with special characters', async ({ page }) => {
|
|
const input = '{{setvar::special}}Hello { world } :: test{{/setvar}}{{getvar::special}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello { world } :: test');
|
|
});
|
|
|
|
test('should set isScoped to true for scoped macro invocation', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-isscoped');
|
|
MacroRegistry.registerMacro('test-isscoped', {
|
|
description: 'Test macro that reports isScoped value.',
|
|
unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
|
|
handler: ({ isScoped }) => `isScoped:${isScoped}`,
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{test-isscoped}}content{{/test-isscoped}}', env);
|
|
});
|
|
expect(output).toBe('isScoped:true');
|
|
});
|
|
|
|
test('should set isScoped to false for inline argument syntax', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
MacroRegistry.unregisterMacro('test-isscoped');
|
|
MacroRegistry.registerMacro('test-isscoped', {
|
|
description: 'Test macro that reports isScoped value.',
|
|
unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
|
|
handler: ({ isScoped }) => `isScoped:${isScoped}`,
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
return MacroEngine.evaluate('{{test-isscoped::content}}', env);
|
|
});
|
|
expect(output).toBe('isScoped:false');
|
|
});
|
|
|
|
test('should keep scoped macro raw when macro accepts no arguments', async ({ page }) => {
|
|
// {{user}} takes no arguments, so {{user}}content{{/user}} should stay raw
|
|
// But content inside should still resolve
|
|
const input = '{{user}}Hello {{char}}!{{/user}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{user}}Hello Character!{{/user}}');
|
|
});
|
|
|
|
test('should keep scoped macro raw when argument count exceeds maximum', async ({ page }) => {
|
|
// setvar takes 2 args (name, value). With scoped content as 3rd arg, it exceeds max.
|
|
// When already at max args, scoped content would be extra - should stay raw
|
|
const input = '{{setvar::myvar::existing}}extra{{/setvar}}{{getvar::myvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('{{setvar::myvar::existing}}extra{{/setvar}}');
|
|
});
|
|
|
|
test('should keep scoped macro raw when argument count is below minimum', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
// Register a macro that requires exactly 3 arguments
|
|
MacroRegistry.unregisterMacro('test-3args');
|
|
MacroRegistry.registerMacro('test-3args', {
|
|
description: 'Test macro requiring 3 arguments.',
|
|
unnamedArgs: [
|
|
{ name: 'a', type: 'string', description: 'First' },
|
|
{ name: 'b', type: 'string', description: 'Second' },
|
|
{ name: 'c', type: 'string', description: 'Third' },
|
|
],
|
|
handler: ({ unnamedArgs: [a, b, c] }) => `${a}-${b}-${c}`,
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
// Only 2 args (1 inline + 1 scoped), but needs 3 - should stay raw
|
|
return MacroEngine.evaluate('{{test-3args::first}}second{{/test-3args}}', env);
|
|
});
|
|
expect(output).toBe('{{test-3args::first}}second{{/test-3args}}');
|
|
});
|
|
|
|
test('should evaluate inner macros before outer macro in scoped content', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
|
|
const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
|
|
|
|
// Track evaluation order
|
|
const evalOrder = [];
|
|
|
|
MacroRegistry.unregisterMacro('test-outer');
|
|
MacroRegistry.registerMacro('test-outer', {
|
|
description: 'Outer test macro.',
|
|
unnamedArgs: [{ name: 'content', type: 'string', description: 'Content' }],
|
|
handler: ({ unnamedArgs: [content] }) => {
|
|
evalOrder.push('outer');
|
|
return `[outer:${content}]`;
|
|
},
|
|
});
|
|
|
|
MacroRegistry.unregisterMacro('test-inner');
|
|
MacroRegistry.registerMacro('test-inner', {
|
|
description: 'Inner test macro.',
|
|
handler: () => {
|
|
evalOrder.push('inner');
|
|
return 'INNER';
|
|
},
|
|
});
|
|
|
|
const rawEnv = { content: '' };
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
const result = MacroEngine.evaluate('{{test-outer}}before {{test-inner}} after{{/test-outer}}', env);
|
|
return { result, order: evalOrder.join(',') };
|
|
});
|
|
expect(output.result).toBe('[outer:before INNER after]');
|
|
expect(output.order).toBe('inner,outer');
|
|
});
|
|
|
|
test('should handle scoped macro inside another scoped macro content', async ({ page }) => {
|
|
// Both scoped macros should resolve, inner first
|
|
const input = '{{setvar::outer}}A{{setvar::inner}}B{{/setvar}}C{{/setvar}}{{getvar::outer}}|{{getvar::inner}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
// inner = "B", outer = "A" + "" + "C" = "AC" (setvar returns empty string)
|
|
expect(output).toBe('AC|B');
|
|
});
|
|
|
|
test('should auto-trim whitespace-only scoped content to empty', async ({ page }) => {
|
|
const input = '{{setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[]');
|
|
});
|
|
|
|
test('should preserve whitespace-only scoped content with # flag', async ({ page }) => {
|
|
const input = '{{#setvar::ws}} {{/setvar}}[{{getvar::ws}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[ ]');
|
|
});
|
|
|
|
test('should handle scoped macro at start of input', async ({ page }) => {
|
|
const input = '{{setvar::x}}value{{/setvar}}result:{{getvar::x}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('result:value');
|
|
});
|
|
|
|
test('should handle scoped macro at end of input', async ({ page }) => {
|
|
const input = 'prefix {{setvar::x}}value{{/setvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('prefix ');
|
|
});
|
|
|
|
test('should handle consecutive scoped macros', async ({ page }) => {
|
|
const input = '{{setvar::a}}1{{/setvar}}{{setvar::b}}2{{/setvar}}{{setvar::c}}3{{/setvar}}{{getvar::a}}{{getvar::b}}{{getvar::c}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('123');
|
|
});
|
|
|
|
test('should handle scoped macro with only macro content (no plaintext)', async ({ page }) => {
|
|
const input = '{{setvar::x}}{{user}}{{/setvar}}{{getvar::x}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('User');
|
|
});
|
|
|
|
test('should not match closing tag across different macro instances', async ({ page }) => {
|
|
// Two separate setvar macros - second closing should not match first opening
|
|
const input = '{{setvar::a}}first{{/setvar}}middle{{setvar::b}}second{{/setvar}}[{{getvar::a}}][{{getvar::b}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('middle[first][second]');
|
|
});
|
|
|
|
test.describe('scoped macros nested inside arguments', () => {
|
|
test('should resolve scoped macro inside another macro argument', async ({ page }) => {
|
|
// {{reverse}}hello{{/reverse}} inside setvar's value argument should resolve first
|
|
const input = '{{setvar::testvar::{{reverse}}hello{{/reverse}}}} {{getvar::testvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' olleh');
|
|
});
|
|
|
|
test('should resolve scoped if macro inside setvar argument', async ({ page }) => {
|
|
// {{if true}}true branch{{/if}} inside setvar should resolve to "true branch"
|
|
const input = '{{setvar::testvar::{{if true}}true branch{{/if}}}} {{getvar::testvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' true branch');
|
|
});
|
|
|
|
test('should resolve scoped if/else macro inside setvar argument', async ({ page }) => {
|
|
const input = '{{setvar::testvar::{{if 0}}wrong{{else}}correct{{/if}}}} {{getvar::testvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' correct');
|
|
});
|
|
|
|
test('should resolve multiple scoped macros inside single argument', async ({ page }) => {
|
|
// Two scoped macros in the same argument
|
|
const input = '{{setvar::testvar::{{reverse}}ab{{/reverse}}-{{reverse}}cd{{/reverse}}}} {{getvar::testvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' ba-dc');
|
|
});
|
|
|
|
test('should resolve deeply nested scoped macros in arguments', async ({ page }) => {
|
|
// Scoped macro inside scoped macro inside argument
|
|
const input = '{{setvar::outer::{{setvar::inner::{{reverse}}xyz{{/reverse}}}}{{getvar::inner}}}} {{getvar::outer}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' zyx');
|
|
});
|
|
|
|
test('should resolve scoped macro with text before and after in argument', async ({ page }) => {
|
|
const input = '{{setvar::testvar::before {{reverse}}mid{{/reverse}} after}} {{getvar::testvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' before dim after');
|
|
});
|
|
|
|
test('should handle scoped macro inside first argument when macro has multiple args', async ({ page }) => {
|
|
// setvar has two args: name and value. Test scoped in value position.
|
|
const input = '{{setvar::myvar::prefix-{{reverse}}abc{{/reverse}}-suffix}}{{getvar::myvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('prefix-cba-suffix');
|
|
});
|
|
|
|
test('should handle multiline scoped content inside argument', async ({ page }) => {
|
|
const input = '{{setvar::testvar::{{if true}}\ntrue\nbranch\n{{/if}}}} {{getvar::testvar}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe(' true\nbranch');
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('{{if}} conditional macro', () => {
|
|
test.describe('with literal values', () => {
|
|
test('should return content when condition is truthy string', async ({ page }) => {
|
|
const input = '{{if::hello::shown}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('shown');
|
|
});
|
|
|
|
test('should return empty when condition is empty string', async ({ page }) => {
|
|
const input = '{{if::::hidden}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
test('should return empty when condition is "false"', async ({ page }) => {
|
|
const input = '{{if::false::hidden}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
test('should return empty when condition is "off"', async ({ page }) => {
|
|
const input = '{{if::off::hidden}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
test('should return empty when condition is "0"', async ({ page }) => {
|
|
const input = '{{if::0::hidden}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
test('should return content when condition is "true"', async ({ page }) => {
|
|
const input = '{{if::true::shown}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('shown');
|
|
});
|
|
|
|
test('should return content when condition is "1"', async ({ page }) => {
|
|
const input = '{{if::1::shown}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('shown');
|
|
});
|
|
});
|
|
|
|
test.describe('with macro name resolution', () => {
|
|
test('should resolve macro name and return content when macro returns truthy', async ({ page }) => {
|
|
// {{char}} returns "Character" (set in test env)
|
|
const input = '{{if char}}Name: {{char}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Name: Character');
|
|
});
|
|
|
|
test('should resolve macro name and return empty when macro returns empty', async ({ page }) => {
|
|
// {{noop}} is a registered macro that always returns empty string
|
|
const input = '{{if noop}}should not show{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
|
|
test('should not resolve non-existent macro names (treat as literal)', async ({ page }) => {
|
|
// "notamacro" is not registered, so it's truthy as a literal string
|
|
const input = '{{if::notamacro::shown}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('shown');
|
|
});
|
|
|
|
test('should resolve user macro and show content', async ({ page }) => {
|
|
// {{user}} returns "User" (set in test env)
|
|
const input = '{{if user}}Hello {{user}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User');
|
|
});
|
|
});
|
|
|
|
test.describe('with nested macros in condition', () => {
|
|
test('should evaluate nested macro in condition (truthy)', async ({ page }) => {
|
|
const input = '{{setvar::flag::yes}}{{if {{getvar::flag}}}}shown{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('shown');
|
|
});
|
|
|
|
test('should evaluate nested macro in condition (falsy)', async ({ page }) => {
|
|
const input = '{{setvar::flag::}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
|
|
test('should evaluate nested macro in condition (false string)', async ({ page }) => {
|
|
const input = '{{setvar::flag::false}}{{if {{getvar::flag}}}}hidden{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
});
|
|
|
|
test.describe('scoped usage', () => {
|
|
test('should work with scoped content (truthy)', async ({ page }) => {
|
|
const input = '{{if yes}}This is the content{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('This is the content');
|
|
});
|
|
|
|
test('should work with scoped content (falsy)', async ({ page }) => {
|
|
const input = '{{if::}}This should not show{{/if}}[after]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[after]');
|
|
});
|
|
|
|
test('should handle macros inside scoped content', async ({ page }) => {
|
|
const input = '{{if yes}}Hello {{user}}!{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User!');
|
|
});
|
|
|
|
test('should handle nested if macros', async ({ page }) => {
|
|
const input = '{{if yes}}outer{{if yes}}inner{{/if}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('outerinner');
|
|
});
|
|
|
|
test('should handle nested if with outer false', async ({ page }) => {
|
|
const input = '{{if::}}outer{{if yes}}inner{{/if}}{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
|
|
test('should handle nested if with inner false', async ({ page }) => {
|
|
const input = '{{if yes}}outer{{if::}}inner{{/if}}end{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('outerend');
|
|
});
|
|
});
|
|
|
|
test.describe('with space-separated condition', () => {
|
|
test('should work with space-separated condition (truthy)', async ({ page }) => {
|
|
const input = '{{if something}}content{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('content');
|
|
});
|
|
|
|
test('should resolve macro name with space-separated syntax', async ({ page }) => {
|
|
const input = '{{if char}}{{char}} exists{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Character exists');
|
|
});
|
|
});
|
|
|
|
test.describe('with {{else}} branch', () => {
|
|
test('should return then-branch when condition is truthy', async ({ page }) => {
|
|
const input = '{{if yes}}then{{else}}else{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('then');
|
|
});
|
|
|
|
test('should return else-branch when condition is falsy', async ({ page }) => {
|
|
const input = '{{if::}}then{{else}}else{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('else');
|
|
});
|
|
|
|
test('should return else-branch when condition is "false"', async ({ page }) => {
|
|
const input = '{{if::false}}yes{{else}}no{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('no');
|
|
});
|
|
|
|
test('should handle macros in both branches', async ({ page }) => {
|
|
const input = '{{if yes}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Hello User');
|
|
});
|
|
|
|
test('should handle macros in else branch when falsy', async ({ page }) => {
|
|
const input = '{{if::}}Hello {{user}}{{else}}Goodbye {{char}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Goodbye Character');
|
|
});
|
|
|
|
test('should handle nested if-else in then-branch', async ({ page }) => {
|
|
const input = '{{if yes}}outer-then{{if yes}}inner-then{{else}}inner-else{{/if}}{{else}}outer-else{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('outer-theninner-then');
|
|
});
|
|
|
|
test('should handle nested if-else in else-branch', async ({ page }) => {
|
|
const input = '{{if::}}outer-then{{else}}outer-else{{if yes}}inner-then{{else}}inner-else{{/if}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('outer-elseinner-then');
|
|
});
|
|
|
|
test('should handle deeply nested if-else', async ({ page }) => {
|
|
const input = '{{if::}}A{{else}}B{{if::}}C{{else}}D{{/if}}{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('BD');
|
|
});
|
|
|
|
test('should return empty else-branch if not provided', async ({ page }) => {
|
|
const input = '{{if::}}content{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
|
|
test('should trim whitespace from branches', async ({ page }) => {
|
|
const input = '{{if yes}} then {{else}} else {{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('then');
|
|
});
|
|
|
|
test('should trim newlines from branches', async ({ page }) => {
|
|
const input = '{{if yes}}\n then\n{{else}}\n else\n{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('then');
|
|
});
|
|
|
|
test('should trim else branch when selected', async ({ page }) => {
|
|
const input = '{{if::}}\n then\n{{else}}\n else\n{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('else');
|
|
});
|
|
|
|
test('should resolve macro name in condition with else branch', async ({ page }) => {
|
|
const input = '{{if char}}Has char{{else}}No char{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Has char');
|
|
});
|
|
|
|
test('should handle empty macro returning else branch', async ({ page }) => {
|
|
const input = '{{if noop}}Has value{{else}}Empty{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Empty');
|
|
});
|
|
});
|
|
|
|
test.describe('with inverted condition (!)', () => {
|
|
test('should invert truthy condition to falsy', async ({ page }) => {
|
|
const input = '{{if !yes}}shown{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
|
|
test('should invert falsy condition to truthy', async ({ page }) => {
|
|
const input = '{{if !false}}shown{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('shown');
|
|
});
|
|
|
|
test('should invert empty string to truthy', async ({ page }) => {
|
|
const input = '{{if::!}}not shown{{else}}shown{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
// Note: "!" is not empty, so it's truthy - but this tests literal ! as value
|
|
expect(output).toBe('not shown');
|
|
});
|
|
|
|
test('should work with ! prefix and macro name', async ({ page }) => {
|
|
// noop returns empty string, so !noop should be truthy
|
|
const input = '{{if !noop}}No value{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('No value');
|
|
});
|
|
|
|
test('should work with ! prefix and truthy macro', async ({ page }) => {
|
|
// char returns "Character", so !char should be falsy
|
|
const input = '{{if !char}}No char{{else}}Has char{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Has char');
|
|
});
|
|
|
|
test('should work with ! prefix and nested macro', async ({ page }) => {
|
|
// Set a variable to empty, then check !{{getvar}}
|
|
const input = '{{setvar::emptyVar::}}{{if !{{getvar::emptyVar}}}}Empty var{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Empty var');
|
|
});
|
|
|
|
test('should NOT invert when ! comes from resolved value', async ({ page }) => {
|
|
// Set a variable starting with !, then check without ! prefix
|
|
// The ! in the value should NOT cause inversion
|
|
const input = '{{setvar::bangVar::!hello}}{{if {{getvar::bangVar}}}}Has value{{else}}No value{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('Has value');
|
|
});
|
|
|
|
test('should work with else branch on inverted condition', async ({ page }) => {
|
|
const input = '{{if !yes}}then{{else}}else{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('else');
|
|
});
|
|
|
|
test('should work with separator syntax', async ({ page }) => {
|
|
const input = '{{if::!something}}shown{{/if}}[end]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[end]');
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('scoped content auto-trim', () => {
|
|
test('should auto-trim scoped content by default', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n content with whitespace \n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[content with whitespace]');
|
|
});
|
|
|
|
test('should auto-trim leading newlines in scoped content', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n\n\ntext{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[text]');
|
|
});
|
|
|
|
test('should auto-trim trailing newlines in scoped content', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}text\n\n\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[text]');
|
|
});
|
|
|
|
test('should dedent consistent indentation when auto-trimming', async ({ page }) => {
|
|
// Both lines have 2-space indent, so dedent removes it from both
|
|
const input = '{{setvar::myvar}}\n line1\n line2 \n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[line1\nline2]');
|
|
});
|
|
|
|
test('should preserve whitespace with # flag', async ({ page }) => {
|
|
const input = '{{#setvar::myvar}}\n content \n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[\n content \n]');
|
|
});
|
|
|
|
test('should preserve leading newlines with # flag', async ({ page }) => {
|
|
const input = '{{#setvar::myvar}}\n\ntext{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[\n\ntext]');
|
|
});
|
|
|
|
test('should preserve trailing newlines with # flag', async ({ page }) => {
|
|
const input = '{{#setvar::myvar}}text\n\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[text\n\n]');
|
|
});
|
|
|
|
test('should work with # flag and nested macros', async ({ page }) => {
|
|
const input = '{{#setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[\n Character \n]');
|
|
});
|
|
|
|
test('should auto-trim with nested macros by default', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n {{char}} \n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[Character]');
|
|
});
|
|
|
|
test('should auto-trim {{if}} scoped content', async ({ page }) => {
|
|
const input = '{{if yes}}\n trimmed \n{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('trimmed');
|
|
});
|
|
|
|
test('should preserve {{if}} whitespace with # flag', async ({ page }) => {
|
|
const input = '{{#if yes}}\n preserved \n{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
// With # flag, both outer content AND branch trimming is skipped
|
|
expect(output).toBe('\n preserved \n');
|
|
});
|
|
|
|
test('should auto-trim {{reverse}} scoped content', async ({ page }) => {
|
|
const input = '{{reverse}}\n abc \n{{/reverse}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('cba');
|
|
});
|
|
|
|
test('should preserve {{reverse}} whitespace with # flag', async ({ page }) => {
|
|
const input = '{{#reverse}}\n abc \n{{/reverse}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('\n cba \n');
|
|
});
|
|
|
|
test('should dedent consistent indentation from multiline content', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n # Heading\n Content here\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[# Heading\nContent here]');
|
|
});
|
|
|
|
test('should dedent based on first non-empty line indentation', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n line1\n line2\n line3\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[line1\nline2\nline3]');
|
|
});
|
|
|
|
test('should preserve relative indentation when dedenting', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n parent\n child\n sibling\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[parent\n child\nsibling]');
|
|
});
|
|
|
|
test('should handle mixed indentation levels correctly', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n # Header\n - item1\n - item2\n Paragraph\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[# Header\n - item1\n - item2\nParagraph]');
|
|
});
|
|
|
|
test('should dedent {{if}} branches with indentation', async ({ page }) => {
|
|
const input = '{{if yes}}\n # Title\n Body text\n{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('# Title\nBody text');
|
|
});
|
|
|
|
test('should dedent {{if}} else branch with indentation', async ({ page }) => {
|
|
const input = '{{if false}}\n Then branch\n{{else}}\n # Else Title\n Else body\n{{/if}}';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('# Else Title\nElse body');
|
|
});
|
|
|
|
test('should not dedent when # flag is set', async ({ page }) => {
|
|
const input = '{{#setvar::myvar}}\n # Heading\n Content\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[\n # Heading\n Content\n]');
|
|
});
|
|
|
|
test('should handle single line content without dedent issues', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n single line\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[single line]');
|
|
});
|
|
|
|
test('should handle empty lines in multiline content', async ({ page }) => {
|
|
const input = '{{setvar::myvar}}\n line1\n\n line2\n{{/setvar}}[{{getvar::myvar}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[line1\n\nline2]');
|
|
});
|
|
|
|
test('should dedent based on first non-empty line and preserve relative indentation', async ({ page }) => {
|
|
// First non-empty line has 2-space indent, subsequent lines have varying indentation
|
|
// The 2-space base indent should be removed, preserving relative indentation
|
|
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}}]';
|
|
const output = await evaluateWithEngine(page, input);
|
|
expect(output).toBe('[First Line\n Second Line, more indented\nThird line\n Fourth line, also more indented]');
|
|
});
|
|
});
|
|
|
|
test.describe('Pre/Post Processor Registration', () => {
|
|
test('should run custom pre-processor before macro evaluation', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Add a pre-processor that replaces [[USER]] with {{user}}
|
|
const handler = (text) => text.replace(/\[\[USER\]\]/g, '{{user}}');
|
|
MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:custom-user-marker' });
|
|
|
|
try {
|
|
const input = 'Hello [[USER]]!';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
|
|
return MacroEngine.evaluate(input, env);
|
|
} finally {
|
|
MacroEngine.removePreProcessor(handler);
|
|
}
|
|
});
|
|
|
|
expect(output).toBe('Hello TestUser!');
|
|
});
|
|
|
|
test('should run custom post-processor after macro evaluation', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Add a post-processor that wraps output in brackets
|
|
const handler = (text) => `[${text}]`;
|
|
MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:bracket-wrapper' });
|
|
|
|
try {
|
|
const input = 'Hello {{user}}!';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'TestUser' });
|
|
return MacroEngine.evaluate(input, env);
|
|
} finally {
|
|
MacroEngine.removePostProcessor(handler);
|
|
}
|
|
});
|
|
|
|
expect(output).toBe('[Hello TestUser!]');
|
|
});
|
|
|
|
test('should execute pre-processors in priority order (lower first)', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// First handler (priority 200) appends 'B'
|
|
const handlerB = (text) => text + 'B';
|
|
// Second handler (priority 100) appends 'A' - should run first despite being registered second
|
|
const handlerA = (text) => text + 'A';
|
|
|
|
MacroEngine.addPreProcessor(handlerB, { priority: 200, source: 'test:append-b' });
|
|
MacroEngine.addPreProcessor(handlerA, { priority: 100, source: 'test:append-a' });
|
|
|
|
try {
|
|
const input = 'X';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
return MacroEngine.evaluate(input, env);
|
|
} finally {
|
|
MacroEngine.removePreProcessor(handlerA);
|
|
MacroEngine.removePreProcessor(handlerB);
|
|
}
|
|
});
|
|
|
|
// Priority 100 (A) runs before priority 200 (B), so: X -> XA -> XAB
|
|
expect(output).toBe('XAB');
|
|
});
|
|
|
|
test('should execute post-processors in priority order (lower first)', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// First handler (priority 200) wraps with ()
|
|
const handlerParen = (text) => `(${text})`;
|
|
// Second handler (priority 100) wraps with [] - should run first
|
|
const handlerBracket = (text) => `[${text}]`;
|
|
|
|
MacroEngine.addPostProcessor(handlerParen, { priority: 200, source: 'test:wrap-paren' });
|
|
MacroEngine.addPostProcessor(handlerBracket, { priority: 100, source: 'test:wrap-bracket' });
|
|
|
|
try {
|
|
const input = 'X';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
return MacroEngine.evaluate(input, env);
|
|
} finally {
|
|
MacroEngine.removePostProcessor(handlerBracket);
|
|
MacroEngine.removePostProcessor(handlerParen);
|
|
}
|
|
});
|
|
|
|
// Priority 100 ([]) runs before priority 200 (()), so: X -> [X] -> ([X])
|
|
expect(output).toBe('([X])');
|
|
});
|
|
|
|
test('should successfully remove a registered pre-processor', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
const handler = (text) => text + '-ADDED';
|
|
MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:to-remove' });
|
|
|
|
// Remove it immediately
|
|
const removed = MacroEngine.removePreProcessor(handler);
|
|
|
|
const input = 'Test';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
const result = MacroEngine.evaluate(input, env);
|
|
|
|
return { result, removed };
|
|
});
|
|
|
|
expect(output.removed).toBe(true);
|
|
expect(output.result).toBe('Test'); // No '-ADDED' suffix
|
|
});
|
|
|
|
test('should return false when removing non-existent processor', async ({ page }) => {
|
|
const removed = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
|
|
const handler = () => 'never registered';
|
|
return MacroEngine.removePreProcessor(handler);
|
|
});
|
|
|
|
expect(removed).toBe(false);
|
|
});
|
|
|
|
test('should pass env to pre-processor handlers', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Pre-processor that uses env to get the user name
|
|
/** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
|
|
const handler = (text, env) => text.replace('__NAME__', env.names.user);
|
|
MacroEngine.addPreProcessor(handler, { priority: 100, source: 'test:env-access' });
|
|
|
|
try {
|
|
const input = 'Hello __NAME__!';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name1Override: 'EnvUser' });
|
|
return MacroEngine.evaluate(input, env);
|
|
} finally {
|
|
MacroEngine.removePreProcessor(handler);
|
|
}
|
|
});
|
|
|
|
expect(output).toBe('Hello EnvUser!');
|
|
});
|
|
|
|
test('should pass env to post-processor handlers', async ({ page }) => {
|
|
const output = await page.evaluate(async () => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Post-processor that appends the character name from env
|
|
/** @param {string} text @param {import('../../public/scripts/macros/engine/MacroEnv.types.js').MacroEnv} env */
|
|
const handler = (text, env) => `${text} (by ${env.names.char})`;
|
|
MacroEngine.addPostProcessor(handler, { priority: 100, source: 'test:env-access-post' });
|
|
|
|
try {
|
|
const input = 'Message';
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input, name2Override: 'EnvChar' });
|
|
return MacroEngine.evaluate(input, env);
|
|
} finally {
|
|
MacroEngine.removePostProcessor(handler);
|
|
}
|
|
});
|
|
|
|
expect(output).toBe('Message (by EnvChar)');
|
|
});
|
|
});
|
|
|
|
test.describe('Variable Shorthand Syntax', () => {
|
|
// {{.myvar}} - get local variable
|
|
test('should get local variable with . shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar}}', { local: { myvar: 'hello' } });
|
|
expect(output).toBe('hello');
|
|
});
|
|
|
|
// {{$myvar}} - get global variable
|
|
test('should get global variable with $ shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar}}', { global: { myvar: 'world' } });
|
|
expect(output).toBe('world');
|
|
});
|
|
|
|
// {{.myvar = value}} - set local variable (setvar returns empty string)
|
|
test('should set local variable with = shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = test}}Value: {{.myvar}}', { local: {} });
|
|
// setvar returns '', then "Value: ", then getvar returns "test"
|
|
expect(output).toBe('Value: test');
|
|
});
|
|
|
|
// {{.counter++}} - increment local variable (incvar returns new value)
|
|
test('should increment local variable with ++ shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.counter++}}', { local: { counter: '5' } });
|
|
expect(output).toBe('6');
|
|
});
|
|
|
|
// {{$counter--}} - decrement global variable (decvar returns new value)
|
|
test('should decrement global variable with -- shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$counter--}}', { global: { counter: '10' } });
|
|
expect(output).toBe('9');
|
|
});
|
|
|
|
// {{.myvar += 5}} - add to local variable (addvar returns empty string)
|
|
test('should add to local variable with += shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += 3}}Then: {{.myvar}}', { local: { myvar: '7' } });
|
|
// addvar returns '', then "Then: ", then getvar returns "10"
|
|
expect(output).toBe('Then: 10');
|
|
});
|
|
|
|
// Nested macro in value: {{.myvar = {{user}}}}
|
|
test('should support nested macro in variable value', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.greeting = Hello {{user}}}}{{.greeting}}', { local: {} });
|
|
// setvar returns '', then getvar returns "Hello User"
|
|
expect(output).toBe('Hello User');
|
|
});
|
|
|
|
// Whitespace handling: {{ .myvar = value }}
|
|
test('should handle whitespace in variable shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar = spaced }}{{.myvar}}', { local: {} });
|
|
// setvar returns '', then getvar returns "spaced"
|
|
expect(output).toBe('spaced');
|
|
});
|
|
|
|
// Variable with hyphen in name: {{.my-var}}
|
|
test('should handle variable name with hyphens', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.my-var}}', { local: { 'my-var': 'hyphenated' } });
|
|
expect(output).toBe('hyphenated');
|
|
});
|
|
|
|
// Variable with underscore: {{.my_var}}
|
|
test('should handle variable name with underscores', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.my_var}}', { local: { 'my_var': 'underscored' } });
|
|
expect(output).toBe('underscored');
|
|
});
|
|
|
|
// Non-existent variable returns empty string
|
|
test('should return empty string for non-existent variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, 'Value:[{{.nonexistent}}]', { local: {} });
|
|
expect(output).toBe('Value:[]');
|
|
});
|
|
|
|
// Increment non-existent variable (should start from 0)
|
|
test('should increment non-existent variable starting from 0', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.newcounter++}}', { local: {} });
|
|
expect(output).toBe('1');
|
|
});
|
|
|
|
// Chain multiple operations
|
|
test('should handle multiple variable operations in sequence', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.x = 5}}{{.x++}}{{.x += 10}}{{.x}}', { local: {} });
|
|
// setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
|
|
expect(output).toBe('616');
|
|
});
|
|
|
|
// {{.myvar -= 5}} - subtract from local variable
|
|
test('should subtract from local variable with -= shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= 3}}Then: {{.myvar}}', { local: { myvar: '10' } });
|
|
// subvar returns '', then "Then: ", then getvar returns "7"
|
|
expect(output).toBe('Then: 7');
|
|
});
|
|
|
|
// {{$myvar -= 5}} - subtract from global variable
|
|
test('should subtract from global variable with -= shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar -= 5}}{{$myvar}}', { global: { myvar: '20' } });
|
|
expect(output).toBe('15');
|
|
});
|
|
|
|
// {{.myvar || default}} - returns default when falsy
|
|
test('should return default value with || when variable is falsy (empty)', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '' } });
|
|
expect(output).toBe('fallback');
|
|
});
|
|
|
|
test('should return default value with || when variable is falsy (zero)', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '0' } });
|
|
expect(output).toBe('fallback');
|
|
});
|
|
|
|
test('should return variable value with || when truthy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: 'existing' } });
|
|
expect(output).toBe('existing');
|
|
});
|
|
|
|
test('should return default value with || when variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.nonexistent || default}}', { local: {} });
|
|
expect(output).toBe('default');
|
|
});
|
|
|
|
// {{.myvar ?? default}} - returns default only when undefined
|
|
test('should return default value with ?? when variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: {} });
|
|
expect(output).toBe('fallback');
|
|
});
|
|
|
|
test('should return empty string with ?? when variable exists but is empty', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? fallback}}]', { local: { myvar: '' } });
|
|
expect(output).toBe('[]');
|
|
});
|
|
|
|
test('should return zero with ?? when variable exists and is zero', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: '0' } });
|
|
expect(output).toBe('0');
|
|
});
|
|
|
|
test('should return variable value with ?? when it exists', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: 'value' } });
|
|
expect(output).toBe('value');
|
|
});
|
|
|
|
// {{.myvar ||= default}} - sets and returns default when falsy
|
|
test('should set and return default with ||= when variable is falsy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: '' } });
|
|
// ||= returns 'newval', then getvar also returns 'newval'
|
|
expect(output).toBe('newvalnewval');
|
|
});
|
|
|
|
test('should not set and return current with ||= when variable is truthy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: 'existing' } });
|
|
// ||= returns 'existing', then getvar returns 'existing'
|
|
expect(output).toBe('existingexisting');
|
|
});
|
|
|
|
test('should set and return default with ||= when variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= created}}{{.myvar}}', { local: {} });
|
|
expect(output).toBe('createdcreated');
|
|
});
|
|
|
|
// {{.myvar ??= default}} - sets and returns default only when undefined
|
|
test('should set and return default with ??= when variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= created}}{{.myvar}}', { local: {} });
|
|
expect(output).toBe('createdcreated');
|
|
});
|
|
|
|
test('should not set and return current with ??= when variable exists but is empty', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ??= newval}}][{{.myvar}}]', { local: { myvar: '' } });
|
|
// ??= returns '' (current value), then getvar returns '' (unchanged)
|
|
expect(output).toBe('[][]');
|
|
});
|
|
|
|
test('should not set and return current with ??= when variable exists and is zero', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= newval}}{{.myvar}}', { local: { myvar: '0' } });
|
|
// ??= returns '0', then getvar returns '0'
|
|
expect(output).toBe('00');
|
|
});
|
|
|
|
// {{.myvar == value}} - equality comparison
|
|
test('should return true when variable equals value with ==', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == hello}}', { local: { myvar: 'hello' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when variable does not equal value with ==', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == world}}', { local: { myvar: 'hello' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should compare empty variable correctly with ==', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ==}}', { local: { myvar: '' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should compare numeric value correctly with ==', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == 42}}', { local: { myvar: '42' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
// {{.myvar != value}} - inequality comparison
|
|
test('should return true when variable does not equal value with !=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != world}}', { local: { myvar: 'hello' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when variable equals value with !=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != hello}}', { local: { myvar: 'hello' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should compare empty variable correctly with !=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar !=}}', { local: { myvar: '' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should compare non-empty to empty with !=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != }}', { local: { myvar: 'value' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should compare numeric value correctly with !=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar != 99}}', { local: { myvar: '42' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
// {{.myvar > value}} - greater than comparison (numeric)
|
|
test('should return true when variable is greater than value with >', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when variable is not greater than value with >', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '5' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should return false when variable equals value with >', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should return false for non-numeric values with >', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: 'abc' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
// {{.myvar >= value}} - greater than or equal comparison (numeric)
|
|
test('should return true when variable is greater than value with >=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 5}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return true when variable equals value with >=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when variable is less than value with >=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '5' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
// {{.myvar < value}} - less than comparison (numeric)
|
|
test('should return true when variable is less than value with <', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '5' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when variable is not less than value with <', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should return false when variable equals value with <', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should return false for non-numeric values with <', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: 'abc' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
// {{.myvar <= value}} - less than or equal comparison (numeric)
|
|
test('should return true when variable is less than value with <=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '5' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return true when variable equals value with <=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when variable is greater than value with <=', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 5}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
// Negative numbers with comparison operators
|
|
test('should handle negative numbers with > operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar > -5}}', { local: { myvar: '0' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should handle negative numbers with < operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 0}}', { local: { myvar: '-5' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
// Decimal numbers with comparison operators
|
|
test('should handle decimal numbers with >= operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 3.14}}', { local: { myvar: '3.14' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should handle decimal numbers with <= operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 2.5}}', { local: { myvar: '2.49' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
// Global variable versions of new operators
|
|
test('should use || with global variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });
|
|
expect(output).toBe('globaldefault');
|
|
});
|
|
|
|
test('should use ?? with global variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar ?? globaldefault}}', { global: {} });
|
|
expect(output).toBe('globaldefault');
|
|
});
|
|
|
|
test('should use ||= with global variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar ||= gset}}{{$myvar}}', { global: { myvar: '' } });
|
|
expect(output).toBe('gsetgset');
|
|
});
|
|
|
|
test('should use ??= with global variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar ??= gcreated}}{{$myvar}}', { global: {} });
|
|
expect(output).toBe('gcreatedgcreated');
|
|
});
|
|
|
|
test('should use == with global variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{$myvar == test}}', { global: { myvar: 'test' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
// Nested macro in fallback value
|
|
test('should support nested macro in || fallback value', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || Hello {{user}}}}', { local: {} });
|
|
expect(output).toBe('Hello User');
|
|
});
|
|
|
|
test('should support nested macro in ?? fallback value', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? Hello {{user}}}}', { local: {} });
|
|
expect(output).toBe('Hello User');
|
|
});
|
|
|
|
// Whitespace handling with new operators
|
|
test('should handle whitespace with || operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar || spaced }}', { local: {} });
|
|
expect(output).toBe('spaced');
|
|
});
|
|
|
|
test('should handle whitespace with ?? operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar ?? spaced }}', { local: {} });
|
|
expect(output).toBe('spaced');
|
|
});
|
|
});
|
|
|
|
test.describe('Variable Shorthand Lazy Evaluation', () => {
|
|
// Tests to verify that fallback value expressions are only evaluated when needed.
|
|
// This is important for performance and because some macros are stateful.
|
|
|
|
// ?? should NOT evaluate fallback when variable exists
|
|
test('should NOT evaluate ?? fallback when variable exists', async ({ page }) => {
|
|
// Use setvar in the fallback - if lazy evaluation works, tracker should remain unset
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'exists' } });
|
|
// myvar exists, so ?? returns 'exists' and the fallback (which would set tracker) is NOT evaluated
|
|
expect(output).toBe('exists[]');
|
|
});
|
|
|
|
test('should evaluate ?? fallback when variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: {} });
|
|
// myvar doesn't exist, so ?? evaluates and returns the fallback, setting tracker
|
|
expect(output).toBe('fallback[evaluated]');
|
|
});
|
|
|
|
// || should NOT evaluate fallback when variable is truthy
|
|
test('should NOT evaluate || fallback when variable is truthy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
|
|
// myvar is truthy, so || returns 'truthy' and the fallback is NOT evaluated
|
|
expect(output).toBe('truthy[]');
|
|
});
|
|
|
|
test('should evaluate || fallback when variable is falsy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: '' } });
|
|
// myvar is falsy, so || evaluates and returns the fallback, setting tracker
|
|
expect(output).toBe('fallback[evaluated]');
|
|
});
|
|
|
|
// ??= should NOT evaluate value when variable exists
|
|
test('should NOT evaluate ??= value when variable exists', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'exists' } });
|
|
// myvar exists, so ??= returns current value and the value expression is NOT evaluated
|
|
expect(output).toBe('exists[]');
|
|
});
|
|
|
|
test('should evaluate ??= value when variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: {} });
|
|
// myvar doesn't exist, so ??= evaluates value, sets myvar, and returns it
|
|
expect(output).toBe('newval[evaluated]');
|
|
});
|
|
|
|
// ||= should NOT evaluate value when variable is truthy
|
|
test('should NOT evaluate ||= value when variable is truthy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
|
|
// myvar is truthy, so ||= returns current value and the value expression is NOT evaluated
|
|
expect(output).toBe('truthy[]');
|
|
});
|
|
|
|
test('should evaluate ||= value when variable is falsy', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: '' } });
|
|
// myvar is falsy, so ||= evaluates value, sets myvar, and returns it
|
|
expect(output).toBe('newval[evaluated]');
|
|
});
|
|
|
|
// Operators that ALWAYS evaluate value should still work
|
|
test('should always evaluate = value expression', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = {{.tracker = evaluated}}value}}[{{.tracker}}]', { local: {} });
|
|
expect(output).toBe('[evaluated]');
|
|
});
|
|
|
|
test('should always evaluate += value expression', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += {{.tracker = evaluated}}5}}[{{.tracker}}]', { local: { myvar: '10' } });
|
|
expect(output).toBe('[evaluated]');
|
|
});
|
|
|
|
test('should always evaluate == value expression', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == {{.tracker = evaluated}}test}}[{{.tracker}}]', { local: { myvar: 'test' } });
|
|
expect(output).toBe('true[evaluated]');
|
|
});
|
|
|
|
// Value should only be evaluated once (caching test)
|
|
test('should only evaluate value expression once when needed', async ({ page }) => {
|
|
// Use addvar to track how many times the value is evaluated (addvar returns empty string)
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.counter = 0}}{{.myvar ??= {{.counter += 1}}value}}{{.counter}}', { local: {} });
|
|
// counter should be 1 (value evaluated exactly once)
|
|
expect(output).toBe('value1');
|
|
});
|
|
});
|
|
|
|
test.describe('Variable Shorthand Edge Cases', () => {
|
|
// Operators requiring a value but value is empty
|
|
test('should handle = operator with empty value', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = }}[{{.myvar}}]', { local: {} });
|
|
// Empty value after = should set the variable to empty string
|
|
expect(output).toBe('[]');
|
|
});
|
|
|
|
test('should handle += operator with empty value', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += }}[{{.myvar}}]', { local: { myvar: 'existing' } });
|
|
// Empty value after += should add nothing
|
|
expect(output).toBe('[existing]');
|
|
});
|
|
|
|
test('should handle -= operator with empty value (non-numeric)', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= }}[{{.myvar}}]', { local: { myvar: '10' } });
|
|
// Empty value is NaN, so subtraction fails silently and returns empty
|
|
expect(output).toBe('[10]');
|
|
});
|
|
|
|
test('should handle || operator with empty fallback', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar || }}]', { local: { myvar: '' } });
|
|
// Falsy myvar, empty fallback - returns empty string
|
|
expect(output).toBe('[]');
|
|
});
|
|
|
|
test('should handle ?? operator with empty fallback', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? }}]', { local: {} });
|
|
// Undefined myvar, empty fallback - returns empty string
|
|
expect(output).toBe('[]');
|
|
});
|
|
|
|
test('should handle == operator with empty comparison value', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: '' } });
|
|
// Empty var equals empty value - should be true
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should handle == operator comparing non-empty to empty', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: 'value' } });
|
|
// Non-empty var vs empty value - should be false
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
// Operators that don't take values - should return raw if invalid
|
|
test('should return raw with trailing content after ++ operator', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar++5}}', { local: { myvar: '5' } });
|
|
expect(output).toBe('{{.myvar++5}}');
|
|
});
|
|
|
|
test('should return empty with trailing content after -- operator', async ({ page }) => {
|
|
// This is a weird case. The "--" operator does not accept value expression, but writing it like this,
|
|
// makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
|
|
// This is intended, so this resolving to null, as the variable does not exist, is also intended.
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar--5}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
test('should return raw with trailing content after -- operator separated by spaces', async ({ page }) => {
|
|
// This is a weird case. The "--" operator does not accept value expression, but writing it like this,
|
|
// makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
|
|
// This is intended, so this resolving to null, as the variable does not exist, is also intended.
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.myvar -- 5}}', { local: { myvar: '10' } });
|
|
expect(output).toBe('{{.myvar -- 5}}');
|
|
});
|
|
});
|
|
|
|
test.describe('Variable Shorthand in {{if}} Macro', () => {
|
|
// {{if .myvar}}...{{/if}} - truthy local variable
|
|
test('should evaluate truthy local variable in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '1' } });
|
|
expect(output).toBe('Yes');
|
|
});
|
|
|
|
// {{if .myvar}}...{{/if}} - falsy local variable
|
|
test('should evaluate falsy local variable in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '' } });
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
// {{if $globalvar}}...{{/if}} - truthy global variable
|
|
test('should evaluate truthy global variable in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if $enabled}}Active{{/if}}', { global: { enabled: 'true' } });
|
|
expect(output).toBe('Active');
|
|
});
|
|
|
|
// {{if !.myvar}}...{{/if}} - inverted condition
|
|
test('should evaluate inverted variable condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if !.flag}}Not set{{/if}}', { local: { flag: '' } });
|
|
expect(output).toBe('Not set');
|
|
});
|
|
|
|
// {{if !$globalvar}}...{{/if}} - inverted global
|
|
test('should evaluate inverted global variable condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if !$disabled}}Enabled{{/if}}', { global: { disabled: '' } });
|
|
expect(output).toBe('Enabled');
|
|
});
|
|
|
|
// {{if ! .myvar}}...{{/if}} - inverted with whitespace
|
|
test('should evaluate inverted condition with whitespace after !', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if ! .empty}}Empty{{/if}}', { local: { empty: '' } });
|
|
expect(output).toBe('Empty');
|
|
});
|
|
|
|
// Non-existent variable is falsy
|
|
test('should treat non-existent variable as falsy in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .nonexistent}}Yes{{else}}No{{/if}}', { local: {} });
|
|
expect(output).toBe('No');
|
|
});
|
|
|
|
// {{if .myvar}}...{{else}}...{{/if}} - with else branch
|
|
test('should handle else branch with variable shorthand', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .active}}On{{else}}Off{{/if}}', { local: { active: 'yes' } });
|
|
expect(output).toBe('On');
|
|
});
|
|
|
|
// Variable with hyphen in if condition
|
|
test('should handle variable with hyphen in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .is-valid}}Valid{{/if}}', { local: { 'is-valid': '1' } });
|
|
expect(output).toBe('Valid');
|
|
});
|
|
|
|
// Combine set and if
|
|
test('should work with variable set before if check', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{.ready = yes}}{{if .ready}}Ready!{{/if}}', { local: {} });
|
|
expect(output).toBe('Ready!');
|
|
});
|
|
|
|
// Zero is falsy
|
|
test('should treat zero as falsy in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Has count{{else}}No count{{/if}}', { local: { count: '0' } });
|
|
expect(output).toBe('No count');
|
|
});
|
|
|
|
// Non-zero number is truthy
|
|
test('should treat non-zero number as truthy in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Count: {{.count}}{{/if}}', { local: { count: '42' } });
|
|
expect(output).toBe('Count: 42');
|
|
});
|
|
});
|
|
|
|
const getUniqueVariableId = () => `dt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
|
|
test.describe('Delayed Argument Resolution ({{if}} branch isolation)', () => {
|
|
// Core feature: setvar in non-chosen branch should NOT execute
|
|
test('should NOT execute setvar in false branch', async ({ page }) => {
|
|
const id = getUniqueVariableId();
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.del(id);
|
|
|
|
const input = `{{if 0}}{{setvar::${id}::should-not-set}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
// Variable should NOT be set because the branch was not taken
|
|
const result = ctx.variables.local.get(id);
|
|
ctx.variables.local.del(id);
|
|
return result;
|
|
}, id);
|
|
|
|
expect(output).toBe('');
|
|
});
|
|
|
|
// setvar in true branch SHOULD execute
|
|
test('should execute setvar in true branch', async ({ page }) => {
|
|
const id = getUniqueVariableId();
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.del(id);
|
|
|
|
const input = `{{if 1}}{{setvar::${id}::was-set}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
const result = ctx.variables.local.get(id);
|
|
ctx.variables.local.del(id);
|
|
return result;
|
|
}, id);
|
|
|
|
expect(output).toBe('was-set');
|
|
});
|
|
|
|
// With else branch: only the chosen branch's setvar should execute
|
|
test('should only execute setvar in chosen else branch', async ({ page }) => {
|
|
const id = getUniqueVariableId();
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.del(id);
|
|
|
|
const input = `{{if 0}}{{setvar::${id}::then-branch}}{{else}}{{setvar::${id}::else-branch}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
const result = ctx.variables.local.get(id);
|
|
ctx.variables.local.del(id);
|
|
return result;
|
|
}, id);
|
|
|
|
expect(output).toBe('else-branch');
|
|
});
|
|
|
|
// Verify then branch setvar executes, not else branch
|
|
test('should only execute setvar in chosen then branch', async ({ page }) => {
|
|
const id = getUniqueVariableId();
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.del(id);
|
|
|
|
const input = `{{if 1}}{{setvar::${id}::then-branch}}{{else}}{{setvar::${id}::else-branch}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
const result = ctx.variables.local.get(id);
|
|
ctx.variables.local.del(id);
|
|
return result;
|
|
}, id);
|
|
|
|
expect(output).toBe('then-branch');
|
|
});
|
|
|
|
// Multiple setvars in branches - only chosen branch's setvars execute
|
|
test('should execute multiple setvars only in chosen branch', async ({ page }) => {
|
|
const id = getUniqueVariableId();
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.del(`${id}_a`);
|
|
ctx.variables.local.del(`${id}_b`);
|
|
|
|
const input = `{{if 0}}{{setvar::${id}_a::wrong}}{{setvar::${id}_b::wrong}}{{else}}{{setvar::${id}_a::right}}{{setvar::${id}_b::right}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
const a = ctx.variables.local.get(`${id}_a`);
|
|
const b = ctx.variables.local.get(`${id}_b`);
|
|
ctx.variables.local.del(`${id}_a`);
|
|
ctx.variables.local.del(`${id}_b`);
|
|
return `a=${a},b=${b}`;
|
|
}, id);
|
|
|
|
expect(output).toBe('a=right,b=right');
|
|
});
|
|
|
|
// Nested if with delayed resolution - inner if should also work correctly
|
|
test('should handle nested if with delayed resolution', async ({ page }) => {
|
|
const id = `dt_nested_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.del(`${id}_outer`);
|
|
ctx.variables.local.del(`${id}_inner`);
|
|
|
|
// Outer if is true, inner if is false
|
|
const input = `{{if 1}}{{setvar::${id}_outer::yes}}{{if 0}}{{setvar::${id}_inner::wrong}}{{else}}{{setvar::${id}_inner::correct}}{{/if}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
const outer = ctx.variables.local.get(`${id}_outer`);
|
|
const inner = ctx.variables.local.get(`${id}_inner`);
|
|
ctx.variables.local.del(`${id}_outer`);
|
|
ctx.variables.local.del(`${id}_inner`);
|
|
return `outer=${outer},inner=${inner}`;
|
|
}, id);
|
|
|
|
expect(output).toBe('outer=yes,inner=correct');
|
|
});
|
|
|
|
// Variable-based condition with delayed resolution
|
|
test('should work with variable shorthand condition and delayed resolution', async ({ page }) => {
|
|
const id = `dt_varsh_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const output = await page.evaluate(async (id) => {
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
ctx.variables.local.set(`${id}_flag`, '');
|
|
ctx.variables.local.del(`${id}_result`);
|
|
|
|
const input = `{{if .${id}_flag}}{{setvar::${id}_result::truthy}}{{else}}{{setvar::${id}_result::falsy}}{{/if}}`;
|
|
const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
|
|
MacroEngine.evaluate(input, env);
|
|
|
|
const result = ctx.variables.local.get(`${id}_result`);
|
|
ctx.variables.local.del(`${id}_flag`);
|
|
ctx.variables.local.del(`${id}_result`);
|
|
return result;
|
|
}, id);
|
|
|
|
expect(output).toBe('falsy');
|
|
});
|
|
|
|
// Inline {{if}} should not break outer {{else}} detection
|
|
test('should handle inline if inside scoped if with else', async ({ page }) => {
|
|
const output = await evaluateWithEngine(page, '{{if 0}}{{if::1::inner}}{{else}}outer-else{{/if}}');
|
|
expect(output).toBe('outer-else');
|
|
});
|
|
|
|
// Another inline if scenario - inner inline if should not affect outer else
|
|
test('should correctly find outer else with multiple inline ifs', async ({ page }) => {
|
|
const output = await evaluateWithEngine(page, '{{if 0}}{{if::1::a}}{{if::1::b}}{{else}}found{{/if}}');
|
|
expect(output).toBe('found');
|
|
});
|
|
});
|
|
|
|
test.describe('Variable Macros (hasvar, deletevar)', () => {
|
|
// {{hasvar::name}} - check if local variable exists
|
|
test('should return true when local variable exists', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: 'value' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when local variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::nonexistent}}', { local: {} });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
test('should return true when local variable exists but is empty', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: '' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
// {{hasglobalvar::name}} - check if global variable exists
|
|
test('should return true when global variable exists', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
|
|
expect(output).toBe('true');
|
|
});
|
|
|
|
test('should return false when global variable does not exist', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::nonexistent}}', { global: {} });
|
|
expect(output).toBe('false');
|
|
});
|
|
|
|
// {{deletevar::name}} - delete local variable
|
|
test('should delete local variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}{{deletevar::myvar}}{{hasvar::myvar}}', { local: { myvar: 'value' } });
|
|
expect(output).toBe('truefalse');
|
|
});
|
|
|
|
// {{deleteglobalvar::name}} - delete global variable
|
|
test('should delete global variable', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}{{deleteglobalvar::myvar}}{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
|
|
expect(output).toBe('truefalse');
|
|
});
|
|
|
|
// Combining hasvar with if
|
|
test('should use hasvar in if condition', async ({ page }) => {
|
|
const output = await evaluateWithEngineAndVariables(page, '{{if {{hasvar::myvar}} == true}}exists{{else}}missing{{/if}}', { local: { myvar: '' } });
|
|
expect(output).toBe('exists');
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Evaluates the given input string using the MacroEngine inside the browser
|
|
* context, ensuring that the core macros are registered.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string} input
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function evaluateWithEngine(page, input) {
|
|
const result = await page.evaluate(async (input) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
|
|
const rawEnv = {
|
|
content: input,
|
|
name1Override: 'User',
|
|
name2Override: 'Character',
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
const output = await MacroEngine.evaluate(input, env);
|
|
return output;
|
|
}, input);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Evaluates the given input string while capturing whether any macro-related
|
|
* warnings or errors were logged to the browser console.
|
|
*
|
|
* This is useful for tests that want to assert both the resolved output and
|
|
* whether the lexer/parser/engine reported issues (e.g. unterminated macros).
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string} input
|
|
* @returns {Promise<{ output: string, hasMacroWarnings: boolean, hasMacroErrors: boolean }>}
|
|
*/
|
|
async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
|
|
/** @type {boolean} */
|
|
let hasMacroWarnings = false;
|
|
/** @type {boolean} */
|
|
let hasMacroErrors = false;
|
|
|
|
/** @param {import('playwright').ConsoleMessage} msg */
|
|
const handler = (msg) => {
|
|
const text = msg.text();
|
|
if (text.includes('[Macro] Warning:')) {
|
|
hasMacroWarnings = true;
|
|
}
|
|
if (text.includes('[Macro] Error:')) {
|
|
hasMacroErrors = true;
|
|
}
|
|
};
|
|
|
|
page.on('console', handler);
|
|
try {
|
|
const output = await evaluateWithEngine(page, input);
|
|
return { output, hasMacroWarnings, hasMacroErrors };
|
|
} finally {
|
|
page.off('console', handler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluates the given input string with pre-set variables.
|
|
* Variables are set via SillyTavern.getContext().variables which is where
|
|
* the variable macros read/write their data.
|
|
*
|
|
* @param {import('@playwright/test').Page} page
|
|
* @param {string} input
|
|
* @param {{ local?: Record<string, string>, global?: Record<string, string> }} variables
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function evaluateWithEngineAndVariables(page, input, variables) {
|
|
const result = await page.evaluate(async ({ input, variables }) => {
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
|
|
const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
|
|
const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
|
|
|
|
// Get the SillyTavern context for variable access
|
|
const ctx = SillyTavern.getContext();
|
|
|
|
// Pre-set local variables
|
|
if (variables.local) {
|
|
for (const [key, value] of Object.entries(variables.local)) {
|
|
ctx.variables.local.set(key, value);
|
|
}
|
|
}
|
|
// Pre-set global variables
|
|
if (variables.global) {
|
|
for (const [key, value] of Object.entries(variables.global)) {
|
|
ctx.variables.global.set(key, value);
|
|
}
|
|
}
|
|
|
|
/** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
|
|
const rawEnv = {
|
|
content: input,
|
|
name1Override: 'User',
|
|
name2Override: 'Character',
|
|
};
|
|
const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
|
|
|
|
const output = await MacroEngine.evaluate(input, env);
|
|
return output;
|
|
}, { input, variables });
|
|
|
|
return result;
|
|
}
|