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 | * @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), |
| @@ -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 | */ |
| 126 | 129 | ||
| 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 | }; |
| 374 | 379 | ||
| 375 | const result = def.handler(executionContext); | 380 | const result = def.handler(executionContext); |
| @@ -818,6 +818,59 @@ test.describe('MacroEngine', () => { | |||
| 818 | // Should be one of the valid options | 818 | // 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 | }); |
| 822 | 875 | ||
| 823 | test.describe('Dynamic macros', () => { | 876 | test.describe('Dynamic macros', () => { |