import { describe, it, expect, vi } from 'vitest'; import { renderMacro, replaceAllMacrosAsync, isBuiltInMacro, normalizeMacroName, type PythonMacroRendererContract, type PythonMacroScript, } from '../../src/main/engine/PageRenderer'; describe('isBuiltInMacro', () => { it('returns true for all known JS built-in macro names', () => { expect(isBuiltInMacro('youtube')).toBe(true); expect(isBuiltInMacro('vimeo')).toBe(true); expect(isBuiltInMacro('gallery')).toBe(true); expect(isBuiltInMacro('photo_archive')).toBe(true); expect(isBuiltInMacro('tag_cloud')).toBe(true); expect(isBuiltInMacro('photo_album')).toBe(true); }); it('returns false for unknown macro names', () => { expect(isBuiltInMacro('my_custom_macro')).toBe(false); expect(isBuiltInMacro('python_report')).toBe(false); expect(isBuiltInMacro('data_table')).toBe(false); }); }); describe('replaceAllMacrosAsync', () => { it('replaces built-in JS macros without Python renderer', async () => { const result = await replaceAllMacrosAsync( 'Before [[youtube id="abc123"]] After', 'post-1', [], null, [], 'en', null, ); expect(result).toContain('class="macro-youtube"'); expect(result).toContain('abc123'); expect(result).not.toContain('[[youtube'); }); it('replaces Python macros when renderer is provided', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([ { id: 'script-1', slug: 'my_widget', entrypoint: 'render', content: 'def render(ctx): return {"html": "
Widget
"}', version: 1, }, ] satisfies PythonMacroScript[]), renderMacro: vi.fn().mockResolvedValue({ html: '
Hello from Python
', }), }; const result = await replaceAllMacrosAsync( 'Before [[my_widget title="Hello"]] After', 'post-1', [], null, [], 'en', mockRenderer, ); expect(result).toBe('Before
Hello from Python
After'); expect(mockRenderer.renderMacro).toHaveBeenCalledWith( expect.objectContaining({ scriptContent: 'def render(ctx): return {"html": "
Widget
"}', entrypoint: 'render', cacheKey: 'script-1:1', }), ); }); it('preserves JS macros alongside Python macros', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([ { id: 'script-2', slug: 'custom_box', entrypoint: 'render', content: 'def render(ctx): return {"html": ""}', version: 3, }, ] satisfies PythonMacroScript[]), renderMacro: vi.fn().mockResolvedValue({ html: '', }), }; const result = await replaceAllMacrosAsync( '[[youtube id="xyz789"]] then [[custom_box]]', 'post-1', [], null, [], 'en', mockRenderer, ); expect(result).toContain('class="macro-youtube"'); expect(result).toContain('xyz789'); expect(result).toContain(''); }); it('preserves unknown macros without Python renderer', async () => { const result = await replaceAllMacrosAsync( 'Before [[unknown_macro]] After', 'post-1', [], null, [], 'en', null, ); expect(result).toBe('Before [[unknown_macro]] After'); }); it('preserves unmatched Python macros', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([]), renderMacro: vi.fn(), }; const result = await replaceAllMacrosAsync( 'Before [[nonexistent_macro]] After', 'post-1', [], null, [], 'en', mockRenderer, ); expect(result).toBe('Before [[nonexistent_macro]] After'); expect(mockRenderer.renderMacro).not.toHaveBeenCalled(); }); it('handles Python macro rendering errors gracefully', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([ { id: 'script-err', slug: 'broken_macro', entrypoint: 'render', content: 'def render(ctx): raise Exception("oops")', version: 1, }, ] satisfies PythonMacroScript[]), renderMacro: vi.fn().mockRejectedValue(new Error('Python execution failed')), }; const result = await replaceAllMacrosAsync( 'Before [[broken_macro]] After', 'post-1', [], null, [], 'en', mockRenderer, ); expect(result).toBe('Before After'); }); it('handles script resolution errors gracefully', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockRejectedValue(new Error('DB error')), renderMacro: vi.fn(), }; const result = await replaceAllMacrosAsync( 'Before [[my_macro]] After', 'post-1', [], null, [], 'en', mockRenderer, ); expect(result).toBe('Before [[my_macro]] After'); }); it('preserves the original unknown macro tag including params', async () => { const result = await replaceAllMacrosAsync( 'Before [[unknown_macro title="Hello" count="2"]] After', 'post-1', [], null, [], 'en', null, ); expect(result).toBe('Before [[unknown_macro title="Hello" count="2"]] After'); }); it('does not look up Python scripts when all macros are built-in', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([]), renderMacro: vi.fn(), }; await replaceAllMacrosAsync( '[[youtube id="a"]] [[vimeo id="1"]]', 'post-1', [], null, [], 'en', mockRenderer, ); expect(mockRenderer.getEnabledMacroScripts).not.toHaveBeenCalled(); }); it('passes correct context to Python macro renderer', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([ { id: 'ctx-script', slug: 'ctx_test', entrypoint: 'run', content: 'def run(ctx): return {"html": "ok"}', version: 2, }, ] satisfies PythonMacroScript[]), renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }), }; await replaceAllMacrosAsync( '[[ctx_test name="Alice" count="5"]]', 'post-42', [], null, [], 'de', mockRenderer, ); const call = (mockRenderer.renderMacro as ReturnType).mock.calls[0][0]; const parsedContext = JSON.parse(call.contextJson); expect(parsedContext.env.isPreview).toBe(false); expect(parsedContext.env.mainLanguage).toBe('de'); expect(parsedContext.env.hook).toBe('ctx_test'); expect(parsedContext.env.source).toEqual({ kind: 'macro', id: 'ctx-script' }); expect(parsedContext.params).toEqual({ name: 'Alice', count: '5' }); expect(call.entrypoint).toBe('run'); expect(call.cacheKey).toBe('ctx-script:2'); }); it('passes languagePrefix and translations in Python macro context', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([ { id: 'lang-script', slug: 'lang_test', entrypoint: 'render', content: 'def render(ctx, post): return {"html": "ok"}', version: 1, }, ] satisfies PythonMacroScript[]), renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }), }; await replaceAllMacrosAsync( '[[lang_test]]', 'post-1', [], null, [], 'fr', mockRenderer, null, '/fr', ); const call = (mockRenderer.renderMacro as ReturnType).mock.calls[0][0]; const parsedContext = JSON.parse(call.contextJson); expect(parsedContext.env.languagePrefix).toBe('/fr'); expect(parsedContext.env.mainLanguage).toBe('fr'); expect(parsedContext.env.translations).toBeDefined(); expect(typeof parsedContext.env.translations).toBe('object'); expect(parsedContext.env.translations['render.archive']).toBe('Archives'); }); it('passes empty languagePrefix when not provided', async () => { const mockRenderer: PythonMacroRendererContract = { getEnabledMacroScripts: vi.fn().mockResolvedValue([ { id: 'no-prefix-script', slug: 'no_prefix', entrypoint: 'render', content: 'def render(ctx, post): return {"html": "ok"}', version: 1, }, ] satisfies PythonMacroScript[]), renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }), }; await replaceAllMacrosAsync( '[[no_prefix]]', 'post-1', [], null, [], 'en', mockRenderer, ); const call = (mockRenderer.renderMacro as ReturnType).mock.calls[0][0]; const parsedContext = JSON.parse(call.contextJson); expect(parsedContext.env.languagePrefix).toBe(''); expect(parsedContext.env.translations).toBeDefined(); }); it('returns unchanged text when there are no macros', async () => { const content = 'Just plain text with no macros'; const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null); expect(result).toBe(content); }); });