Fix `{{pick}}` macro seeding inside delayed-resolution macros like `{{if}}` (#4986) - Thread `contextOffset` through `MacroEngine.evaluate()` to preserve global document position - Update `resolve()` in execution context to pass caller's `globalOffset` as `contextOffset` - Add `offsetDelta` option to `resolve()` for additional offset customization - Add regression tests for `{{pick}}` inside `{{if}}` blocks with `delayArgResolution` - Ensure identical picks at different positions produce different results

5c2a02a128218a8460f92b67f39c2a746851bc08

Wolfsblvt <wolfsblvt@gmail.com>

Signed
3 files changed, +66 -4Showing whitespace changes
public/scripts/macros/engine/MacroEngine.js+6 -2
@@ -108,9 +108,13 @@ class MacroEngine {
108 *108 *
109 * @param {string} input - The input string to evaluate.109 * @param {string} input - The input string to evaluate.
110 * @param {MacroEnv} env - The environment to pass to the macro handler.110 * @param {MacroEnv} env - The environment to pass to the macro handler.
111 * @param {Object} [options={}] - Optional evaluation settings.
112 * @param {number} [options.contextOffset=0] - Base offset from the original top-level document.
113 * Used when evaluating nested content (via resolve() in handlers) to preserve global
114 * positioning for macros like {{pick}} that seed on position.
111 * @returns {string} The resolved string.115 * @returns {string} The resolved string.
112 */116 */
113 evaluate(input, env) {117 evaluate(input, env, { contextOffset = 0 } = {}) {
114 if (!input) {118 if (!input) {
115 return '';119 return '';
116 }120 }
@@ -138,7 +142,7 @@ class MacroEngine {
138 try {142 try {
139 evaluated = MacroCstWalker.evaluateDocument({143 evaluated = MacroCstWalker.evaluateDocument({
140 text: preProcessed,144 text: preProcessed,
141 contextOffset: 0,145 contextOffset,
142 cst,146 cst,
143 env: safeEnv,147 env: safeEnv,
144 resolveMacro: this.#resolveMacro.bind(this),148 resolveMacro: this.#resolveMacro.bind(this),
public/scripts/macros/engine/MacroRegistry.js+7 -2
@@ -121,7 +121,10 @@ export const MacroValueType = Object.freeze({
121 * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results.121 * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results.
122 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.122 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
123 * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation.123 * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation.
124 * @property {(text: string) => string} resolve - Evaluates macros in the given text using the same environment. Use when delayArgResolution is true.124 * @property {(text: string, options?: { offsetDelta?: number }) => string} resolve - Evaluates macros in the given text using the same environment.
125 * Use when delayArgResolution is true. By default, preserves the caller's globalOffset so nested
126 * macros like {{pick}} maintain deterministic position-based behavior. Pass offsetDelta to add
127 * an additional offset for uniqueness (e.g., to differentiate between multiple resolve calls).
125 */128 */
126129
127/**130/**
@@ -369,7 +372,9 @@ class MacroRegistry {
369 globalOffset: call.globalOffset,372 globalOffset: call.globalOffset,
370 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),373 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
371 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),374 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
372 resolve: (text) => MacroEngine.evaluate(text, call.env),375 resolve: (text, { offsetDelta = 0 } = {}) => MacroEngine.evaluate(text, call.env, {
376 contextOffset: call.globalOffset + offsetDelta,
377 }),
373 };378 };
374379
375 const result = def.handler(executionContext);380 const result = def.handler(executionContext);
tests/frontend/MacroEngine.e2e.js+53 -0
@@ -818,6 +818,59 @@ test.describe('MacroEngine', () => {
818 // Should be one of the valid options818 // Should be one of the valid options
819 expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();819 expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
820 });820 });
821
822 test('should use different seeds for identical picks inside different if blocks (delayArgResolution)', async ({ page }) => {
823 await registerTestablePick(page);
824
825 // Key regression test: picks inside {{if}} blocks use resolve() which must preserve globalOffset
826 // This tests the fix for macros with delayArgResolution that call resolve() internally
827 const output = await page.evaluate(async () => {
828 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
829 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
830 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
831 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
832
833 // Two identical pick macros inside different if blocks
834 // Before the fix, both would get contextOffset=0 when resolve() was called
835 // After the fix, resolve() passes the caller's globalOffset as contextOffset
836 const input = '{{if true}}{{testablePick::A::B::C}}{{/if}}###{{if true}}{{testablePick::A::B::C}}{{/if}}';
837 const env = MacroEnvBuilder.buildFromRawEnv({ content: input });
838 return MacroEngine.evaluate(input, env);
839 });
840
841 const parts = output.split('###');
842 expect(parts.length).toBe(2);
843
844 const seed1 = parts[0].match(/seed:([^|]+)/)?.[1];
845 const seed2 = parts[1].match(/seed:([^|]+)/)?.[1];
846
847 expect(seed1).toBeTruthy();
848 expect(seed2).toBeTruthy();
849 // Seeds must be different because the {{if}} blocks are at different positions
850 expect(seed1).not.toBe(seed2);
851 });
852
853 test('should maintain stability for picks inside if blocks across evaluations', async ({ page }) => {
854 // Picks inside if blocks should still be deterministic
855 const outputs = await page.evaluate(async () => {
856 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
857 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
858 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
859 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
860
861 const input = '{{if true}}{{pick::X::Y::Z}}{{/if}}';
862 const env1 = MacroEnvBuilder.buildFromRawEnv({ content: input });
863 const env2 = MacroEnvBuilder.buildFromRawEnv({ content: input });
864 const result1 = MacroEngine.evaluate(input, env1);
865 const result2 = MacroEngine.evaluate(input, env2);
866 return [result1, result2];
867 });
868
869 // Same input should produce same output (deterministic)
870 expect(outputs[0]).toBe(outputs[1]);
871 // Should be one of the valid options
872 expect(['X', 'Y', 'Z'].includes(outputs[0])).toBeTruthy();
873 });
821 });874 });
822875
823 test.describe('Dynamic macros', () => {876 test.describe('Dynamic macros', () => {