diff --git a/public/scripts/macros/definitions/core-macros.js b/public/scripts/macros/definitions/core-macros.js index 40af630f6..b80c3ec01 100644 --- a/public/scripts/macros/definitions/core-macros.js +++ b/public/scripts/macros/definitions/core-macros.js @@ -345,7 +345,7 @@ export function registerCoreMacros() { description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position.', returns: 'Stable randomly selected item from the list.', exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'], - handler: ({ list, range, env }) => { + handler: ({ list, globalOffset, env }) => { // Handle old legacy cases, where we have to split the list manually if (list.length === 1) { list = readSingleArgsRandomList(list[0]); @@ -355,12 +355,19 @@ export function registerCoreMacros() { return ''; } + // NOTE: + // When changing the hashing logic, make sure to update unit test functionality + // in registerTestablePick() to be identical. + const chatIdHash = getChatIdHash(); // Use the full original input string for deterministic behavior const rawContentHash = env.contentHash; - const offset = typeof range?.startOffset === 'number' ? range.startOffset : 0; + // Use globalOffset for deterministic seeding - this ensures identical macros + // at different positions in the document produce different results, even when + // nested inside arguments or scoped content + const offset = globalOffset; const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`; const finalSeed = getStringHash(combinedSeedString); diff --git a/public/scripts/macros/engine/MacroCstWalker.js b/public/scripts/macros/engine/MacroCstWalker.js index 7135e09b4..8b31ad15b 100644 --- a/public/scripts/macros/engine/MacroCstWalker.js +++ b/public/scripts/macros/engine/MacroCstWalker.js @@ -18,7 +18,10 @@ import { MacroRegistry } from './MacroRegistry.js'; * @property {string} rawInner * @property {string} rawWithBraces * @property {string[]} rawArgs - * @property {{ startOffset: number, endOffset: number }} range + * @property {{ startOffset: number, endOffset: number }} range - Range relative to the current evaluation context's text. + * @property {number} globalOffset - The offset of this macro in the original top-level document. + * This combines the context's base offset with the local range. Use this for deterministic + * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results. * @property {CstNode} cstNode */ @@ -31,10 +34,21 @@ import { MacroRegistry } from './MacroRegistry.js'; */ /** + * Context passed through the CST evaluation process. + * * @typedef {Object} EvaluationContext - * @property {string} text - * @property {MacroEnv} env - * @property {(call: MacroCall) => string} resolveMacro + * @property {string} text - The text being evaluated at the current level. This is NOT the same as env.content. + * At the top level, this is the full document text. When evaluating nested content (arguments or scoped + * content), this is the substring being evaluated. CST node positions are always relative to this text. + * + * - Careful, this also means when resolving macros inside macro arguments, this will NOT be the text of + * the argument currently being resolved, but the full macro text with identifier and all macros. + * @property {number} contextOffset - Base offset from the original top-level document. At the top level this is 0. + * When re-parsing nested content (arguments/scoped), this is set to the substring's start position in + * the original document. Used to calculate globalOffset for macros that need deterministic positioning. + * @property {MacroEnv} env - The macro environment containing context like user/char names, variables, and the + * original full content (env.content). This remains constant throughout the evaluation. + * @property {(call: MacroCall) => string} resolveMacro - Callback to resolve a macro call to its result string. * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Shared utility function that trims scoped content with optional indentation dedent. */ @@ -74,7 +88,7 @@ class MacroCstWalker { * @returns {string} */ evaluateDocument(options) { - const { text, cst, env, resolveMacro, trimContent } = options; + const { text, cst, contextOffset, env, resolveMacro, trimContent } = options; if (typeof text !== 'string') { throw new Error('MacroCstWalker.evaluateDocument: text must be a string'); @@ -90,7 +104,7 @@ class MacroCstWalker { } /** @type {EvaluationContext} */ - const context = { text, env, resolveMacro, trimContent }; + const context = { text, contextOffset, env, resolveMacro, trimContent }; let items = this.#collectDocumentItems(cst); // Process scoped macros: find opening/closing pairs and merge them @@ -341,7 +355,7 @@ class MacroCstWalker { * @returns {string} */ #evaluateMacroNode(macroNode, context, scopedContent) { - const { text, env, resolveMacro, trimContent } = context; + const { text, contextOffset, env, resolveMacro, trimContent } = context; const children = macroNode.children || {}; @@ -467,6 +481,7 @@ class MacroCstWalker { rawWithBraces: text.slice(range.startOffset, range.endOffset + 1), rawArgs, range, + globalOffset: contextOffset + range.startOffset, cstNode: macroNode, env, }; @@ -486,7 +501,7 @@ class MacroCstWalker { * @returns {string} */ #evaluateVariableExpr(macroNode, variableExprNode, context) { - const { text, env, resolveMacro } = context; + const { text, contextOffset, env, resolveMacro } = context; const children = macroNode.children || {}; const varChildren = variableExprNode.children || {}; @@ -560,6 +575,7 @@ class MacroCstWalker { rawWithBraces: text.slice(range.startOffset, range.endOffset + 1), rawArgs: args, range, + globalOffset: contextOffset + range.startOffset, cstNode: macroNode, env, }; @@ -642,9 +658,13 @@ class MacroCstWalker { * Evaluates a single argument node by resolving nested macros and reconstructing * the original argument text. * - * @param {CstNode} argNode - * @param {EvaluationContext} context - * @returns {string} + * This method extracts the argument's raw text and re-parses it to properly + * handle scoped macros (opening/closing tag pairs) that may appear within + * the argument content. + * + * @param {CstNode} argNode - The argument CST node to evaluate. + * @param {EvaluationContext} context - The evaluation context containing the parent document's text and environment. + * @returns {string} The evaluated argument with all nested macros (including scoped ones) resolved. */ #evaluateArgumentNode(argNode, context) { const location = this.#getArgumentLocation(argNode); @@ -652,38 +672,87 @@ class MacroCstWalker { return ''; } - const { text } = context; + const { text, contextOffset } = context; + const rawContent = text.slice(location.startOffset, location.endOffset + 1); - const nestedMacros = /** @type {CstNode[]} */ ((argNode.children || {}).macro || []); + // Calculate the new base offset: parent's contextOffset + this argument's start position + const newContextOffset = contextOffset + location.startOffset; - // If there are no nested macros, we can just return the original text - if (nestedMacros.length === 0) { - return text.slice(location.startOffset, location.endOffset + 1); + // Use the shared helper to evaluate the content, which handles scoped macros + return this.#evaluateRawContent(rawContent, newContextOffset, context); + } + + /** + * Evaluates a text content string by parsing it and resolving all macros, + * including scoped macro pairs (opening/closing tags). + * + * This is the core helper used by both argument evaluation and scoped content + * evaluation to ensure consistent handling of nested and scoped macros. + * + * @param {string} rawContent - The raw text content to evaluate. + * @param {number} newContextOffset - The offset of rawContent's start position in the original top-level document. + * @param {EvaluationContext} context - The parent evaluation context (used for env, resolveMacro, trimContent). + * @returns {string} The evaluated content with all macros resolved. + */ + #evaluateRawContent(rawContent, newContextOffset, context) { + // If empty, return as-is + if (!rawContent) { + return ''; } - // If there are macros, evaluate them one by one in appearing order, inside the argument, before we return the resolved argument - const nestedWithRange = nestedMacros.map(node => ({ - node, - range: this.#getMacroRange(node), - })); + // Re-evaluate the content to find all nested macros including scoped pairs + // We need to parse and evaluate this content as if it were a standalone document + const { cst } = MacroParser.parseDocument(rawContent); - nestedWithRange.sort((a, b) => a.range.startOffset - b.range.startOffset); + // If parsing fails, return the raw content + if (!cst || typeof cst !== 'object' || !cst.children) { + return rawContent; + } + // Create a new context with the content as the text and updated contextOffset + // This is important: positions in the parsed CST are relative to rawContent, + // but contextOffset tracks the absolute position in the original document + /** @type {EvaluationContext} */ + const contentContext = { ...context, text: rawContent, contextOffset: newContextOffset }; + + // Collect items and process scoped macros + let items = this.#collectDocumentItems(cst); + items = this.#processScopedMacros(items, rawContent); + + // If no items, return raw content + if (items.length === 0) { + return rawContent; + } + + // Evaluate items in order let result = ''; - let cursor = location.startOffset; + let cursor = 0; - for (const entry of nestedWithRange) { - if (entry.range.startOffset < cursor) { - continue; + for (const item of items) { + if (item.startOffset > cursor) { + result += rawContent.slice(cursor, item.startOffset); } - result += text.slice(cursor, entry.range.startOffset); - result += this.#evaluateMacroNode(entry.node, context); - cursor = entry.range.endOffset + 1; + if (item.type === 'plaintext') { + result += rawContent.slice(item.startOffset, item.endOffset + 1); + cursor = item.endOffset + 1; + } else if (item.keepRaw) { + // Unmatched closing macros stay as raw text + result += rawContent.slice(item.startOffset, item.endOffset + 1); + cursor = item.endOffset + 1; + } else { + result += this.#evaluateMacroNode(item.node, contentContext, item.scopedContent); + // If this macro has scoped content, skip past the closing macro + if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) { + cursor = item.scopedContent.closingEndOffset + 1; + } else { + cursor = item.endOffset + 1; + } + } } - if (cursor <= location.endOffset) { - result += text.slice(cursor, location.endOffset + 1); + if (cursor < rawContent.length) { + result += rawContent.slice(cursor); } return result; @@ -836,76 +905,22 @@ class MacroCstWalker { * This resolves any nested macros within the scoped content. * * @param {{ startOffset: number, endOffset: number }} scopedContent - The range of the scoped content. - * @param {EvaluationContext} context - The evaluation context. + * @param {EvaluationContext} context - The evaluation context. The `text` property contains the parent + * document text, and offsets in scopedContent are relative to that parent text. * @returns {string} - The evaluated scoped content with nested macros resolved. */ #evaluateScopedContent(scopedContent, context) { - const { text, env, resolveMacro, trimContent } = context; + const { text, contextOffset } = context; const { startOffset, endOffset } = scopedContent; // Extract the raw content between opening and closing tags const rawContent = text.slice(startOffset, endOffset + 1); - // If empty, return empty string - if (!rawContent) { - return ''; - } + // Calculate the new base offset: parent's contextOffset + this scoped content's start position + const newContextOffset = contextOffset + startOffset; - // Re-evaluate the scoped content to resolve any nested macros - // We need to parse and evaluate this content as if it were a standalone document - const { cst: scopedCst } = MacroParser.parseDocument(rawContent); - - // If parsing fails, return the raw content - if (!scopedCst || typeof scopedCst !== 'object' || !scopedCst.children) { - return rawContent; - } - - // Create a new context with the scoped content text - /** @type {EvaluationContext} */ - const scopedContext = { text: rawContent, env, resolveMacro, trimContent }; - - // Collect items from the scoped content CST - let items = this.#collectDocumentItems(scopedCst); - - // Process any nested scoped macros within this content - items = this.#processScopedMacros(items, rawContent); - - // Evaluate the items - if (items.length === 0) { - return rawContent; - } - - let result = ''; - let cursor = 0; - - for (const item of items) { - if (item.startOffset > cursor) { - result += rawContent.slice(cursor, item.startOffset); - } - - if (item.type === 'plaintext') { - result += rawContent.slice(item.startOffset, item.endOffset + 1); - cursor = item.endOffset + 1; - } else if (item.keepRaw) { - // Unmatched closing macros stay as raw text - result += rawContent.slice(item.startOffset, item.endOffset + 1); - cursor = item.endOffset + 1; - } else { - result += this.#evaluateMacroNode(item.node, scopedContext, item.scopedContent); - // If this macro has scoped content, skip past the closing macro - if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) { - cursor = item.scopedContent.closingEndOffset + 1; - } else { - cursor = item.endOffset + 1; - } - } - } - - if (cursor < rawContent.length) { - result += rawContent.slice(cursor); - } - - return result; + // Use the shared helper to evaluate the content + return this.#evaluateRawContent(rawContent, newContextOffset, context); } // ======================================================================== diff --git a/public/scripts/macros/engine/MacroEngine.js b/public/scripts/macros/engine/MacroEngine.js index 6d24d5f30..5d69d45f7 100644 --- a/public/scripts/macros/engine/MacroEngine.js +++ b/public/scripts/macros/engine/MacroEngine.js @@ -138,6 +138,7 @@ class MacroEngine { try { evaluated = MacroCstWalker.evaluateDocument({ text: preProcessed, + contextOffset: 0, cst, env: safeEnv, resolveMacro: this.#resolveMacro.bind(this), diff --git a/public/scripts/macros/engine/MacroRegistry.js b/public/scripts/macros/engine/MacroRegistry.js index cba777b64..6e64407e5 100644 --- a/public/scripts/macros/engine/MacroRegistry.js +++ b/public/scripts/macros/engine/MacroRegistry.js @@ -114,8 +114,11 @@ export const MacroValueType = Object.freeze({ * @property {string} rawOriginal - The original full macro text including braces, before any resolution. * @property {string[]} rawArgs - The original arguments passed to the macro (always unresolved). * @property {MacroEnv} env - * @property {CstNode|null} cstNode - * @property {{ startOffset: number, endOffset: number }|null} range + * @property {CstNode} cstNode + * @property {{ startOffset: number, endOffset: number }} range - Range relative to the current evaluation context's text. + * @property {number} globalOffset - The offset of this macro in the original top-level document. + * This combines the context's base offset with the local range. Use this for deterministic + * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results. * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected. * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation. * @property {(text: string) => string} resolve - Evaluates macros in the given text using the same environment. Use when delayArgResolution is true. @@ -363,6 +366,7 @@ class MacroRegistry { env: call.env, cstNode: call.cstNode, range: call.range, + globalOffset: call.globalOffset, normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine), trimContent: MacroEngine.trimScopedContent.bind(MacroEngine), resolve: (text) => MacroEngine.evaluate(text, call.env), diff --git a/tests/frontend/MacroEngine.e2e.js b/tests/frontend/MacroEngine.e2e.js index 5db5715d4..462a8435e 100644 --- a/tests/frontend/MacroEngine.e2e.js +++ b/tests/frontend/MacroEngine.e2e.js @@ -633,28 +633,69 @@ test.describe('MacroEngine', () => { }); test.describe('Deterministic pick macro', () => { - test('should return stable results for the same chat and content', async ({ page }) => { - // Simulate a consistent chat id hash - let originalHash; - await page.evaluate(async ([originalHash]) => { + /** 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'); - originalHash = chat_metadata.chat_id_hash; - chat_metadata.chat_id_hash = 123456; - }, [originalHash]); + /** @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 combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`; + // 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. + // 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. + // 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(); @@ -663,13 +704,119 @@ test.describe('MacroEngine', () => { expect(options.includes(first)).toBeTruthy(); expect(options.includes(second)).toBeTruthy(); + }); - // Restore original hash - await page.evaluate(async ([originalHash]) => { - /** @type {import('../../public/script.js')} */ - const { chat_metadata } = await import('./script.js'); - chat_metadata.chat_id_hash = originalHash; - }, [originalHash]); + 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(); }); }); @@ -1589,6 +1736,61 @@ test.describe('MacroEngine', () => { 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', () => {