Macros 2.0 [Fix] - Fix macro evaluation to allow nested scoped macros in arguments (#4977)
* Fix scoped macro evaluation to allow nested scoped macros in arguments
- Refactor `#evaluateArgumentNode` to re-parse argument content for scoped macro pairs
- Create shared `#evaluateRawContent` helper method for consistent text evaluation
- Update `#evaluateScopedContent` to use the shared helper, reducing duplication
- Add 8 comprehensive test cases for nested scoped macros in arguments
- Enhance JSDoc documentation for EvaluationContext text parameter
* Fix `{{pick}}` macro to use global document offset for deterministic seeding
- Replace `range.startOffset` with `globalOffset` in pick macro handler for consistent position-based seeding
- Add `contextOffset` to EvaluationContext to track base offset from original document (starts at 0 for top-level)
- Calculate `globalOffset` as `contextOffset + range.startOffset` when building MacroCall objects
- Thread `contextOffset` through evaluation pipeline: MacroEngine → MacroCstWalker → all evaluation
* Fix typo in MacroCstWalker JSDoc comment and thread contextOffset through evaluateDocument
- Correct JSDoc typo: "rull macro text" → "full macro text"
- Destructure `contextOffset` from options in `evaluateDocument` method
- Pass `contextOffset` to EvaluationContext instead of hardcoded 0
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -138,6 +138,7 @@ class MacroEngine {
|
||||
try {
|
||||
evaluated = MacroCstWalker.evaluateDocument({
|
||||
text: preProcessed,
|
||||
contextOffset: 0,
|
||||
cst,
|
||||
env: safeEnv,
|
||||
resolveMacro: this.#resolveMacro.bind(this),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user