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
Signed| @@ -108,9 +108,13 @@ class MacroEngine { | ||
| 108 | 108 | * |
| 109 | 109 | * @param {string} input - The input string to evaluate. |
| 110 | 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 | 115 | * @returns {string} The resolved string. |
| 112 | 116 | */ |
| 113 | 117 | evaluate(input, env, { contextOffset = 0 } = {}) { |
| 114 | 118 | if (!input) { |
| 115 | 119 | return ''; |
| 116 | 120 | } |
| @@ -138,7 +142,7 @@ class MacroEngine { | ||
| 138 | 142 | try { |
| 139 | 143 | evaluated = MacroCstWalker.evaluateDocument({ |
| 140 | 144 | text: preProcessed, |
| 141 | 145 | contextOffset: 0, |
| 142 | 146 | cst, |
| 143 | 147 | env: safeEnv, |
| 144 | 148 | resolveMacro: this.#resolveMacro.bind(this), |
| @@ -121,7 +121,10 @@ export const MacroValueType = Object.freeze({ | ||
| 121 | 121 | * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results. |
| 122 | 122 | * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected. |
| 123 | 123 | * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation. |
| 124 | 124 | * @property {(text: string, options?: { offsetDelta?: number }) => string} resolve - Evaluates macros in the given text using the same environment. Use when delayArgResolution is true. |
| 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 | */ |
| 126 | 129 | |
| 127 | 130 | /** |
| @@ -369,7 +372,9 @@ class MacroRegistry { | ||
| 369 | 372 | globalOffset: call.globalOffset, |
| 370 | 373 | normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine), |
| 371 | 374 | trimContent: MacroEngine.trimScopedContent.bind(MacroEngine), |
| 372 | 375 | resolve: (text, { offsetDelta = 0 } = {}) => MacroEngine.evaluate(text, call.env), { |
| 376 | + contextOffset: call.globalOffset + offsetDelta, | |
| 377 | + }), | |
| 373 | 378 | }; |
| 374 | 379 | |
| 375 | 380 | const result = def.handler(executionContext); |
| @@ -818,6 +818,59 @@ test.describe('MacroEngine', () => { | ||
| 818 | 818 | // Should be one of the valid options |
| 819 | 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 | }); |
| 822 | 875 | |
| 823 | 876 | test.describe('Dynamic macros', () => { |