import { describe, it, expect, beforeEach } from 'vitest' import { useWidgetStore } from './widget-store' import type { ToolRegistration } from './types' describe('WidgetStore', () => { beforeEach(() => { // Reset the store before each test useWidgetStore.setState({ widgets: {}, }) }) describe('setWidget', () => { it('should add a new widget to the store', () => { const widgetId = 'test-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', title: 'Test Formula', isLoading: false, visible: true, data: { value: 100, prefix: '$', }, }) const widgets = useWidgetStore.getState().widgets expect(widgets[widgetId]).toBeDefined() expect(widgets[widgetId]?.id).toBe(widgetId) expect(widgets[widgetId]?.type).toBe('formula') }) it('should update an existing widget', () => { const widgetId = 'test-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', title: 'Test Formula', isLoading: false, visible: true, data: { value: 100, }, }) useWidgetStore.getState().setWidget(widgetId, { title: 'Updated Formula', data: { value: 200, }, }) const widgets = useWidgetStore.getState().widgets expect((widgets[widgetId]?.data as { value: number })?.value).toBe(200) }) it('should handle multiple widgets', () => { useWidgetStore.getState().setWidget('widget-1', { type: 'formula', isLoading: false, visible: true, }) useWidgetStore.getState().setWidget('widget-2', { type: 'bar', isLoading: false, visible: true, }) const widgets = useWidgetStore.getState().widgets expect(Object.keys(widgets)).toHaveLength(2) expect(widgets['widget-1']).toBeDefined() expect(widgets['widget-2']).toBeDefined() }) }) describe('removeWidget', () => { it('should remove a widget from the store', () => { const widgetId = 'test-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', isLoading: false, visible: true, }) expect(useWidgetStore.getState().widgets[widgetId]).toBeDefined() useWidgetStore.getState().removeWidget(widgetId) expect(useWidgetStore.getState().widgets[widgetId]).toBeUndefined() }) it('should handle removing non-existent widget gracefully', () => { useWidgetStore.getState().removeWidget('non-existent') expect(useWidgetStore.getState().widgets).toEqual({}) }) it('should only remove the specified widget', () => { useWidgetStore.getState().setWidget('widget-1', { type: 'formula', isLoading: false, visible: true, }) useWidgetStore.getState().setWidget('widget-2', { type: 'formula', isLoading: false, visible: true, }) useWidgetStore.getState().removeWidget('widget-1') const widgets = useWidgetStore.getState().widgets expect(widgets['widget-1']).toBeUndefined() expect(widgets['widget-2']).toBeDefined() }) }) describe('clearWidgets', () => { it('should remove all widgets', () => { useWidgetStore.getState().setWidget('widget-1', { type: 'formula', isLoading: false, visible: true, }) useWidgetStore.getState().setWidget('widget-2', { type: 'bar', isLoading: false, visible: true, }) expect(Object.keys(useWidgetStore.getState().widgets)).toHaveLength(2) useWidgetStore.getState().clearWidgets() expect(useWidgetStore.getState().widgets).toEqual({}) }) it('should work on empty store', () => { useWidgetStore.getState().clearWidgets() expect(useWidgetStore.getState().widgets).toEqual({}) }) }) describe('getWidget', () => { it('should get a widget by id', () => { const widgetId = 'test-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', title: 'Test Widget', isLoading: false, visible: true, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget).toBeDefined() expect(widget?.id).toBe(widgetId) }) it('should return undefined for non-existent widget', () => { const widget = useWidgetStore.getState().getWidget('non-existent') expect(widget).toBeUndefined() }) }) describe('unknown type data handling', () => { it('should handle widget with unknown type and data structure', () => { const widgetId = 'custom-widget' // Example: A custom widget type with arbitrary data useWidgetStore.getState().setWidget(widgetId, { type: 'custom', title: 'Custom Widget', isLoading: false, visible: true, data: { customField: 'value', nestedData: { foo: 'bar', count: 42, }, }, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget).toBeDefined() expect(widget?.type).toBe('custom') // Type assertion needed when accessing unknown data const customData = widget?.data as { customField: string nestedData: { foo: string; count: number } } expect(customData?.customField).toBe('value') expect(customData?.nestedData?.foo).toBe('bar') expect(customData?.nestedData?.count).toBe(42) }) it('should handle widget updates with different data structures', () => { const widgetId = 'flexible-widget' // Initial data structure useWidgetStore.getState().setWidget(widgetId, { type: 'dynamic', isLoading: false, visible: true, data: { format: 'json', content: { items: [] }, }, }) // Update with different data structure useWidgetStore.getState().setWidget(widgetId, { data: { format: 'text', content: 'Plain text content', metadata: { length: 18, }, }, }) const widget = useWidgetStore.getState().getWidget(widgetId) const widgetData = widget?.data as { format: string content: string metadata: { length: number } } expect(widgetData?.format).toBe('text') expect(widgetData?.content).toBe('Plain text content') expect(widgetData?.metadata?.length).toBe(18) }) it('should handle complex nested unknown data', () => { const widgetId = 'complex-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'analytics', title: 'Analytics Dashboard', isLoading: false, visible: true, data: { metrics: [ { name: 'revenue', value: 1000, trend: 'up' }, { name: 'users', value: 500, trend: 'down' }, ], timeRange: { start: '2025-01-01', end: '2025-01-31', }, settings: { displayMode: 'chart', refreshInterval: 5000, }, }, }) const widget = useWidgetStore.getState().getWidget(widgetId) // Type assertion for complex unknown data const analyticsData = widget?.data as { metrics: { name: string; value: number; trend: string }[] timeRange: { start: string; end: string } settings: { displayMode: string; refreshInterval: number } } expect(analyticsData.metrics).toHaveLength(2) expect(analyticsData.metrics[0]?.name).toBe('revenue') expect(analyticsData.metrics[0]?.value).toBe(1000) expect(analyticsData.timeRange.start).toBe('2025-01-01') expect(analyticsData.settings.displayMode).toBe('chart') }) it('should handle widget with no data', () => { const widgetId = 'no-data-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'placeholder', title: 'Empty Widget', isLoading: false, visible: true, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.data).toBeUndefined() }) it('should preserve unknown type through updates', () => { const widgetId = 'type-test-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'special-type-123', isLoading: false, visible: true, data: { initial: true }, }) useWidgetStore.getState().setWidget(widgetId, { title: 'Updated Title', data: { updated: true }, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.type).toBe('special-type-123') }) }) describe('isFetching property', () => { it('should set isFetching on a new widget', () => { const widgetId = 'fetching-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', title: 'Fetching Widget', isLoading: false, isFetching: true, visible: true, data: { value: 100 }, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isFetching).toBe(true) }) it('should update isFetching on an existing widget', () => { const widgetId = 'update-fetching-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', isLoading: false, isFetching: false, visible: true, }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( false, ) useWidgetStore.getState().setWidget(widgetId, { isFetching: true, }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( true, ) }) it('should handle undefined isFetching (optional property)', () => { const widgetId = 'no-fetching-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', isLoading: false, visible: true, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isFetching).toBeUndefined() }) it('should handle different combinations of isLoading and isFetching', () => { const widgetId = 'state-combination-widget' // Both false useWidgetStore.getState().setWidget(widgetId, { type: 'formula', isLoading: false, isFetching: false, visible: true, }) let widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isLoading).toBe(false) expect(widget?.isFetching).toBe(false) // Loading true, fetching false useWidgetStore.getState().setWidget(widgetId, { isLoading: true, isFetching: false, }) widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isLoading).toBe(true) expect(widget?.isFetching).toBe(false) // Loading false, fetching true useWidgetStore.getState().setWidget(widgetId, { isLoading: false, isFetching: true, }) widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isLoading).toBe(false) expect(widget?.isFetching).toBe(true) // Both true useWidgetStore.getState().setWidget(widgetId, { isLoading: true, isFetching: true, }) widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isLoading).toBe(true) expect(widget?.isFetching).toBe(true) }) it('should toggle isFetching multiple times', () => { const widgetId = 'toggle-fetching-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', isLoading: false, isFetching: false, visible: true, }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( false, ) useWidgetStore.getState().setWidget(widgetId, { isFetching: true }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( true, ) useWidgetStore.getState().setWidget(widgetId, { isFetching: false }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( false, ) useWidgetStore.getState().setWidget(widgetId, { isFetching: true }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( true, ) }) it('should preserve isFetching when updating other properties', () => { const widgetId = 'preserve-fetching-widget' useWidgetStore.getState().setWidget(widgetId, { type: 'formula', title: 'Original Title', isLoading: false, isFetching: true, visible: true, data: { value: 100 }, }) expect(useWidgetStore.getState().getWidget(widgetId)?.isFetching).toBe( true, ) useWidgetStore.getState().setWidget(widgetId, { title: 'Updated Title', data: { value: 200 }, }) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.isFetching).toBe(true) expect((widget?.data as { value: number })?.value).toBe(200) }) }) describe('executeToolPipeline sourceData', () => { const widgetId = 'test-widget-source' beforeEach(() => { useWidgetStore.getState().clearWidgets() }) it('stores sourceData alongside data after pipeline execution', async () => { useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) const source = [{ value: 1 }, { value: 2 }] await useWidgetStore.getState().executeToolPipeline(widgetId, source) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.sourceData).toBe(source) expect(widget?.data).toBe(source) // No tools, passthrough }) it('preserves sourceData when a tool transforms data to empty', async () => { useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) const emptyFilterTool: ToolRegistration = { id: 'empty-filter', order: 10, enabled: true, fn: () => [], // Transforms any data to empty array } useWidgetStore.getState().registerTool(widgetId, emptyFilterTool) const source = [{ value: 1 }, { value: 2 }] await useWidgetStore.getState().executeToolPipeline(widgetId, source) const widget = useWidgetStore.getState().getWidget(widgetId) expect(widget?.sourceData).toBe(source) expect(widget?.data).toEqual([]) }) it('updates sourceData when input changes but pipeline output stays the same', async () => { useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) const constantTool: ToolRegistration = { id: 'constant', order: 10, enabled: true, fn: () => 'constant-output', } useWidgetStore.getState().registerTool(widgetId, constantTool) const source1 = [{ value: 1 }] await useWidgetStore.getState().executeToolPipeline(widgetId, source1) expect(useWidgetStore.getState().getWidget(widgetId)?.sourceData).toBe( source1, ) const source2 = [{ value: 2 }] await useWidgetStore.getState().executeToolPipeline(widgetId, source2) // sourceData should reflect the new input expect(useWidgetStore.getState().getWidget(widgetId)?.sourceData).toBe( source2, ) }) }) describe('Config Tool Pipeline', () => { const widgetId = 'test-widget-config' beforeEach(() => { useWidgetStore.getState().clearWidgets() }) it('executes config tools and sets transformed config', async () => { useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) const configTool: ToolRegistration = { id: 'stack-tool', type: 'config', order: 10, enabled: true, fn: (config) => { const c = config as Record const option = c.option as { series?: { name: string }[] } const series = option?.series ?? [] return { ...c, option: { ...option, series: series.map((s) => ({ ...s, stack: 'group' })), }, } }, } useWidgetStore.getState().registerTool(widgetId, configTool) await useWidgetStore.getState().executeConfigPipeline(widgetId, { option: { series: [{ name: 'Series 1' }, { name: 'Series 2' }], }, }) const widget = useWidgetStore.getState().getWidget(widgetId) const option = (widget as { option?: { series?: { stack?: string }[] } }) ?.option expect(option?.series?.[0]?.stack).toBe('group') expect(option?.series?.[1]?.stack).toBe('group') }) it('passes base config through when no config tools registered', async () => { useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) await useWidgetStore.getState().executeConfigPipeline(widgetId, { option: { series: [{ name: 'Series 1' }] }, }) const widget = useWidgetStore.getState().getWidget(widgetId) const option = (widget as { option?: { series?: { name: string }[] } }) ?.option expect(option?.series?.[0]?.name).toBe('Series 1') }) it('does not include config tools in data pipeline', async () => { const executionOrder: string[] = [] useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) const dataTool: ToolRegistration = { id: 'data-tool', type: 'data', order: 10, enabled: true, fn: (data) => { executionOrder.push('data-tool') return data }, } const configTool: ToolRegistration = { id: 'config-tool', type: 'config', order: 10, enabled: true, fn: (config) => { executionOrder.push('config-tool') return config }, } useWidgetStore.getState().registerTool(widgetId, dataTool) useWidgetStore.getState().registerTool(widgetId, configTool) await useWidgetStore.getState().executeToolPipeline(widgetId, {}) expect(executionOrder).toEqual(['data-tool']) expect(executionOrder).not.toContain('config-tool') }) it('respects disables across tool types', async () => { const executionOrder: string[] = [] useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) const configTool: ToolRegistration = { id: 'config-tool', type: 'config', order: 10, enabled: true, fn: (config) => { executionOrder.push('config-tool') return config }, } const disablerTool: ToolRegistration = { id: 'disabler', type: 'data', order: 10, enabled: true, fn: (data) => data, disables: ['config-tool'], } useWidgetStore.getState().registerTool(widgetId, configTool) useWidgetStore.getState().registerTool(widgetId, disablerTool) await useWidgetStore.getState().executeConfigPipeline(widgetId, { option: {}, }) expect(executionOrder).not.toContain('config-tool') }) it('should not overwrite user-modified state when config lacks that property', async () => { useWidgetStore.getState().setWidget(widgetId, { type: 'table', isLoading: false, }) // Apply initial config (without selected — clean config pattern) await useWidgetStore.getState().executeConfigPipeline(widgetId, { columns: [], selectable: true, mode: 'local', }) // Simulate user selecting rows via UI action useWidgetStore.getState().setWidget(widgetId, { selected: [1, 2, 3], }) // Re-run config pipeline (e.g., triggered by tool registration or data change) await useWidgetStore.getState().executeConfigPipeline(widgetId, { columns: [], selectable: true, mode: 'local', }) // Selection should be preserved — config doesn't include 'selected' const widget = useWidgetStore.getState().getWidget(widgetId) expect((widget as { selected?: (string | number)[] }).selected).toEqual([ 1, 2, 3, ]) }) it('should not overwrite user-modified state when a tool passes base config through unchanged', async () => { const baseConfig = { option: { series: [{ name: 'A' }] }, selected: [] as number[], } useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) // Register a config tool that only changes `option` but spreads // the entire base config (the typical { ...config, option: modified } pattern) useWidgetStore.getState().registerTool(widgetId, { id: 'stack-tool', type: 'config', order: 10, enabled: true, fn: (config: unknown) => { const c = config as typeof baseConfig return { ...c, option: { series: [{ name: 'A', stack: 'group' }] } } }, }) // Initial pipeline sets option and selected await useWidgetStore .getState() .executeConfigPipeline(widgetId, baseConfig) // Simulate user modifying selected via UI useWidgetStore.getState().setWidget(widgetId, { selected: [1, 2] }) // Re-run pipeline — tool still only changes `option`, // but spreads { ...config, option: modified } which includes selected: [] await useWidgetStore .getState() .executeConfigPipeline(widgetId, baseConfig) const widget = useWidgetStore.getState().getWidget(widgetId) as { selected?: number[] option?: { series?: { stack?: string }[] } } // Tool's change should be applied expect(widget.option?.series?.[0]?.stack).toBe('group') // User's selection should be preserved — the tool didn't change `selected` expect(widget.selected).toEqual([1, 2]) }) }) describe('triggerToolPipeline', () => { const widgetId = 'test-widget-trigger' beforeEach(() => { useWidgetStore.getState().clearWidgets() }) it('creates new registeredTools reference', () => { useWidgetStore.getState().setWidget(widgetId, { type: 'bar', isLoading: false, }) useWidgetStore.getState().registerTool(widgetId, { id: 'my-tool', order: 10, enabled: true, fn: (data) => data, }) const toolsBefore = useWidgetStore.getState().widgets[widgetId]?.registeredTools useWidgetStore.getState().triggerToolPipeline(widgetId) const toolsAfter = useWidgetStore.getState().widgets[widgetId]?.registeredTools // New reference — triggers Effect 4 in WidgetLoader expect(toolsBefore).not.toBe(toolsAfter) // But same content expect(toolsAfter?.length).toBe(toolsBefore?.length) expect(toolsAfter?.[0]?.id).toBe(toolsBefore?.[0]?.id) }) it('does nothing for non-existent widget', () => { useWidgetStore.getState().triggerToolPipeline('non-existent') // No error thrown }) }) describe('Tool Dependency Management', () => { const widgetId = 'test-widget-deps' beforeEach(() => { useWidgetStore.getState().clearWidgets() }) describe('Pipeline Filtering', () => { it('skips tools disabled by other enabled tools', async () => { const executionOrder: string[] = [] const toolA: ToolRegistration = { id: 'tool-a', order: 10, enabled: true, fn: (data) => { executionOrder.push('tool-a') return data }, } const toolB: ToolRegistration = { id: 'tool-b', order: 20, enabled: true, fn: (data) => { executionOrder.push('tool-b') return data }, } const toolC: ToolRegistration = { id: 'tool-c', order: 30, enabled: true, fn: (data) => { executionOrder.push('tool-c') return data }, disables: ['tool-b'], } useWidgetStore.getState().registerTool(widgetId, toolA) useWidgetStore.getState().registerTool(widgetId, toolB) useWidgetStore.getState().registerTool(widgetId, toolC) await useWidgetStore.getState().executeToolPipeline(widgetId, {}) // tool-b should be skipped because tool-c disables it expect(executionOrder).toEqual(['tool-a', 'tool-c']) expect(executionOrder).not.toContain('tool-b') }) it('includes tool when disabling tool is not enabled', async () => { const executionOrder: string[] = [] const toolA: ToolRegistration = { id: 'tool-a', order: 10, enabled: true, fn: (data) => { executionOrder.push('tool-a') return data }, } const toolB: ToolRegistration = { id: 'tool-b', order: 20, enabled: false, // Disabled fn: (data) => { executionOrder.push('tool-b') return data }, disables: ['tool-a'], } useWidgetStore.getState().registerTool(widgetId, toolA) useWidgetStore.getState().registerTool(widgetId, toolB) await useWidgetStore.getState().executeToolPipeline(widgetId, {}) // tool-a should execute because tool-b is disabled expect(executionOrder).toEqual(['tool-a']) }) it('handles multiple tools disabling the same target', async () => { const executionOrder: string[] = [] const toolA: ToolRegistration = { id: 'tool-a', order: 10, enabled: true, fn: (data) => { executionOrder.push('tool-a') return data }, } const toolB: ToolRegistration = { id: 'tool-b', order: 20, enabled: true, fn: (data) => { executionOrder.push('tool-b') return data }, disables: ['tool-a'], } const toolC: ToolRegistration = { id: 'tool-c', order: 30, enabled: true, fn: (data) => { executionOrder.push('tool-c') return data }, disables: ['tool-a'], } useWidgetStore.getState().registerTool(widgetId, toolA) useWidgetStore.getState().registerTool(widgetId, toolB) useWidgetStore.getState().registerTool(widgetId, toolC) await useWidgetStore.getState().executeToolPipeline(widgetId, {}) // tool-a should be skipped (disabled by both tool-b and tool-c) expect(executionOrder).toEqual(['tool-b', 'tool-c']) expect(executionOrder).not.toContain('tool-a') }) }) }) })