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:
Wolfsblvt
2026-01-09 22:25:08 +01:00
committed by GitHub
parent 8a32b72dfe
commit 3047045d37
5 changed files with 341 additions and 112 deletions
+218 -16
View File
@@ -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', () => {