/** @jsxImportSource preact */ import {DarkTheme, LightTheme} from '@deck.gl/widgets'; import {h, render} from 'preact'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {WidgetContainerRenderer, asPanelContainer} from './widget-containers'; import {TextEditorPanel} from './text-editor-panel'; const monacoHarness = vi.hoisted(() => { type FakeListener = () => void; type FakeModel = { uri: {toString: () => string}; language: string; getValue: () => string; setValue: (nextValue: string) => void; onDidChangeContent: (listener: FakeListener) => {dispose: () => void}; dispose: ReturnType; emitUserInput: (nextValue: string) => void; }; type FakeEditor = { updateOptions: ReturnType; dispose: ReturnType; }; const modelsByUri = new Map(); const configureJsonSchema = vi.fn(); const clearJsonSchema = vi.fn(); const setTheme = vi.fn(); const loadTextEditorMonacoRuntime = vi.fn(); let lastCreatedModel: FakeModel | null = null; let lastCreatedEditor: FakeEditor | null = null; /** * Builds a fake Monaco runtime used by the text-editor widget tests. */ function createRuntime() { return { monaco: { Uri: { parse: (uri: string) => ({ toString: () => uri }) }, editor: { getModel: (uri: {toString: () => string}) => modelsByUri.get(uri.toString()) ?? null, createModel: (value: string, language: string, uri: {toString: () => string}) => { let currentValue = value; const listeners = new Set(); const model: FakeModel = { uri, language, getValue: () => currentValue, setValue: (nextValue: string) => { currentValue = nextValue; listeners.forEach((listener) => listener()); }, onDidChangeContent: (listener: FakeListener) => { listeners.add(listener); return { dispose: () => listeners.delete(listener) }; }, dispose: vi.fn(), emitUserInput: (nextValue: string) => { currentValue = nextValue; listeners.forEach((listener) => listener()); } }; modelsByUri.set(uri.toString(), model); lastCreatedModel = model; return model; }, create: (_hostElement: HTMLElement, _options: unknown) => { const editor: FakeEditor = { updateOptions: vi.fn(), dispose: vi.fn() }; lastCreatedEditor = editor; return editor; }, setModelLanguage: (model: FakeModel, language: string) => { model.language = language; }, setTheme } }, configureJsonSchema, clearJsonSchema }; } /** * Resets the fake Monaco harness to the default resolved runtime state. */ function reset() { modelsByUri.clear(); configureJsonSchema.mockReset(); clearJsonSchema.mockReset(); setTheme.mockReset(); loadTextEditorMonacoRuntime.mockReset(); loadTextEditorMonacoRuntime.mockResolvedValue(createRuntime()); lastCreatedModel = null; lastCreatedEditor = null; } /** * Returns the most recently created fake Monaco model. */ function getLastCreatedModel(): FakeModel | null { return lastCreatedModel; } /** * Returns the most recently created fake Monaco editor. */ function getLastCreatedEditor(): FakeEditor | null { return lastCreatedEditor; } return { clearJsonSchema, configureJsonSchema, createRuntime, getLastCreatedEditor, getLastCreatedModel, loadTextEditorMonacoRuntime, reset, setTheme }; }); vi.mock('./text-editor-panel-monaco-runtime', () => ({ loadTextEditorMonacoRuntime: monacoHarness.loadTextEditorMonacoRuntime })); /** * Flushes queued microtasks so Preact effects and mocked async loaders settle. */ async function flushMicrotasks(): Promise { await Promise.resolve(); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); } /** * Waits until a test predicate becomes true or fails after a bounded number of retries. */ async function waitForCondition( predicate: () => boolean, message: string, attempts = 8 ): Promise { for (let attempt = 0; attempt < attempts; attempt += 1) { if (predicate()) { return; } await flushMicrotasks(); } throw new Error(message); } afterEach(() => { for (const rootElement of [...document.body.children]) { render(null, rootElement as HTMLElement); } document.body.innerHTML = ''; }); beforeEach(() => { monacoHarness.reset(); }); describe('TextEditorPanel', () => { it('creates a widget panel with the expected id and title', () => { const panel = new TextEditorPanel({ id: 'text-editor', title: 'Text editor' }); expect(panel.id).toBe('text-editor'); expect(panel.title).toBe('Text editor'); }); it('renders a loading state before monaco resolves', () => { const root = document.createElement('div'); document.body.appendChild(root); let resolveRuntime: ((value: unknown) => void) | undefined; const pendingRuntime = new Promise((resolve) => { resolveRuntime = resolve; }); monacoHarness.loadTextEditorMonacoRuntime.mockReturnValueOnce(pendingRuntime); render(new TextEditorPanel({id: 'loading', title: 'Loading'}).content, root); expect(root.querySelector('[data-text-editor-loading]')?.textContent).toContain( 'Loading editor' ); expect(root.querySelector('[data-text-editor-host]')).toBeNull(); resolveRuntime?.(monacoHarness.createRuntime()); }); it('initializes from defaultValue when uncontrolled', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( new TextEditorPanel({ id: 'uncontrolled', title: 'Uncontrolled', defaultValue: '{\"alpha\":1}' }).content, root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created for uncontrolled panel.' ); expect(monacoHarness.getLastCreatedModel()?.getValue()).toBe('{"alpha":1}'); expect(root.querySelector('[data-text-editor-host]')).toBeTruthy(); }); it('uses the light Monaco theme by default', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( h(WidgetContainerRenderer, { container: asPanelContainer(new TextEditorPanel({id: 'light', title: 'Light'})) }), root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created for light theme panel.' ); expect(monacoHarness.setTheme).toHaveBeenCalledWith('vs'); }); it('uses the dark Monaco theme for a dark panel override', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'dark', title: 'Dark', theme: 'dark' }) ) }), root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created for dark theme panel.' ); expect(monacoHarness.setTheme).toHaveBeenCalledWith('vs-dark'); }); it('uses custom Monaco theme ids when provided', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'custom-theme', title: 'Custom theme', theme: 'dark', lightMonacoTheme: 'custom-light', darkMonacoTheme: 'custom-dark' }) ) }), root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created for custom theme panel.' ); expect(monacoHarness.setTheme).toHaveBeenCalledWith('custom-dark'); }); it('updates the Monaco theme when the effective panel theme changes', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'theme-switch', title: 'Theme switch', theme: 'dark' }) ) }), root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created before theme update.' ); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'theme-switch', title: 'Theme switch', theme: 'light' }) ) }), root ); await flushMicrotasks(); await waitForCondition( () => monacoHarness.setTheme.mock.lastCall?.[0] === 'vs', 'Expected Monaco theme to update to the light theme.' ); expect(monacoHarness.setTheme).toHaveBeenLastCalledWith('vs'); }); it('updates to the custom light Monaco theme when the effective panel theme changes', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'custom-theme-switch', title: 'Custom theme switch', theme: 'dark', lightMonacoTheme: 'custom-light', darkMonacoTheme: 'custom-dark' }) ) }), root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created before custom theme update.' ); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'custom-theme-switch', title: 'Custom theme switch', theme: 'light', lightMonacoTheme: 'custom-light', darkMonacoTheme: 'custom-dark' }) ) }), root ); await flushMicrotasks(); await waitForCondition( () => monacoHarness.setTheme.mock.lastCall?.[0] === 'custom-light', 'Expected Monaco theme to update to the custom light theme.' ); expect(monacoHarness.setTheme).toHaveBeenLastCalledWith('custom-light'); }); it('updates the Monaco theme when inherited widget theme variables change', async () => { const widgetContainer = document.createElement('div'); const root = document.createElement('div'); widgetContainer.className = 'deck-widget-container'; widgetContainer.style.setProperty('--menu-background', LightTheme['--menu-background'] ?? ''); widgetContainer.appendChild(root); document.body.appendChild(widgetContainer); render( h(WidgetContainerRenderer, { container: asPanelContainer( new TextEditorPanel({ id: 'theme-vars-switch', title: 'Theme vars switch' }) ) }), root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created before inherited theme update.' ); expect(monacoHarness.setTheme).toHaveBeenLastCalledWith('vs'); widgetContainer.style.setProperty('--menu-background', DarkTheme['--menu-background'] ?? ''); await waitForCondition( () => monacoHarness.setTheme.mock.lastCall?.[0] === 'vs-dark', 'Expected Monaco theme to update after inherited widget theme changes.' ); expect(monacoHarness.setTheme).toHaveBeenLastCalledWith('vs-dark'); }); it('respects controlled value updates', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( new TextEditorPanel({ id: 'controlled', title: 'Controlled', value: '{"alpha":1}' }).content, root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model to be created for controlled panel.' ); expect(monacoHarness.getLastCreatedModel()?.getValue()).toBe('{"alpha":1}'); render( new TextEditorPanel({ id: 'controlled', title: 'Controlled', value: '{"alpha":2}' }).content, root ); await waitForCondition( () => monacoHarness.getLastCreatedModel()?.getValue() === '{"alpha":2}', 'Expected controlled Monaco value to update after rerender.' ); expect(monacoHarness.getLastCreatedModel()?.getValue()).toBe('{"alpha":2}'); }); it('calls onValueChange when the editor content changes', async () => { const root = document.createElement('div'); document.body.appendChild(root); const onValueChange = vi.fn(); render( new TextEditorPanel({ id: 'changes', title: 'Changes', defaultValue: 'start', onValueChange }).content, root ); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null, 'Expected Monaco model before simulating user input.' ); monacoHarness.getLastCreatedModel()?.emitUserInput('next'); await waitForCondition( () => onValueChange.mock.calls.length > 0, 'Expected onValueChange to be called after simulated input.' ); expect(onValueChange).toHaveBeenLastCalledWith('next'); }); it('applies JSON-schema configuration only in json mode', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( new TextEditorPanel({ id: 'json-editor', title: 'JSON editor', jsonSchema: { type: 'object' } }).content, root ); await waitForCondition( () => monacoHarness.configureJsonSchema.mock.calls.length > 0, 'Expected JSON schema registration for JSON editor.' ); expect(monacoHarness.configureJsonSchema).toHaveBeenCalledWith( 'inmemory://deck-gl-community/widgets/json-editor', {type: 'object'} ); render( new TextEditorPanel({ id: 'plain-editor', title: 'Plain editor', language: 'plaintext', jsonSchema: { type: 'object' } }).content, root ); await waitForCondition( () => monacoHarness.clearJsonSchema.mock.calls.some( ([uri]) => uri === 'inmemory://deck-gl-community/widgets/plain-editor' ), 'Expected plain-text mode to clear JSON schema registration.' ); expect(monacoHarness.clearJsonSchema).toHaveBeenCalledWith( 'inmemory://deck-gl-community/widgets/plain-editor' ); }); it('updates schema registration when jsonSchema changes', async () => { const root = document.createElement('div'); document.body.appendChild(root); render( new TextEditorPanel({ id: 'schema-editor', title: 'Schema editor', jsonSchema: { type: 'object', required: ['alpha'] } }).content, root ); await flushMicrotasks(); render( new TextEditorPanel({ id: 'schema-editor', title: 'Schema editor', jsonSchema: { type: 'object', required: ['beta'] } }).content, root ); await flushMicrotasks(); await waitForCondition( () => monacoHarness.configureJsonSchema.mock.lastCall?.[0] === 'inmemory://deck-gl-community/widgets/schema-editor' && JSON.stringify(monacoHarness.configureJsonSchema.mock.lastCall?.[1]) === JSON.stringify({ type: 'object', required: ['beta'] }), 'Expected schema registration to update after jsonSchema changes.' ); expect(monacoHarness.configureJsonSchema).toHaveBeenLastCalledWith( 'inmemory://deck-gl-community/widgets/schema-editor', { type: 'object', required: ['beta'] } ); }); it('disposes editor resources on unmount', async () => { const root = document.createElement('div'); document.body.appendChild(root); render(new TextEditorPanel({id: 'dispose', title: 'Dispose'}).content, root); await waitForCondition( () => monacoHarness.getLastCreatedModel() !== null && monacoHarness.getLastCreatedEditor() !== null, 'Expected Monaco resources before unmount.' ); const model = monacoHarness.getLastCreatedModel(); const editor = monacoHarness.getLastCreatedEditor(); render(null, root); await flushMicrotasks(); expect(editor?.dispose).toHaveBeenCalledTimes(1); expect(model?.dispose).toHaveBeenCalledTimes(1); expect(monacoHarness.clearJsonSchema).toHaveBeenCalledWith( 'inmemory://deck-gl-community/widgets/dispose' ); }); it('renders a load error fallback when monaco loading fails', async () => { const root = document.createElement('div'); document.body.appendChild(root); monacoHarness.loadTextEditorMonacoRuntime.mockRejectedValueOnce(new Error('boom')); render(new TextEditorPanel({id: 'error', title: 'Error'}).content, root); await waitForCondition( () => (root.querySelector('[data-text-editor-error]')?.textContent ?? '').includes('boom'), 'Expected text editor error fallback to render when Monaco loading fails.' ); expect(root.querySelector('[data-text-editor-error]')?.textContent).toContain('boom'); }); });