From 049f1ec147a89130226bddf54f747fae68baf041 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 14 May 2026 21:29:39 +0400 Subject: [PATCH 1/3] fix a few leftovers --- .../ai_assistant_integration_controller.ts | 8 +-- .../data_grid/ai_assistant/commands/index.ts | 1 - .../ai_assistant_integration_controller.ts | 66 ++++++++----------- .../grid_core/ai_assistant/commands/index.ts | 1 - 4 files changed, 33 insertions(+), 43 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts index 0d58b8de2a92..f6eec23660fb 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/ai_assistant_integration_controller.ts @@ -3,8 +3,8 @@ import type { GridContext } from '@ts/grids/grid_core/ai_assistant/types'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; export class DataGridAIAssistantIntegrationController extends AIAssistantIntegrationController { - protected getGridExtraContext(): GridContext { - const context = super.getGridExtraContext(); + protected buildContext(): GridContext { + const context = super.buildContext(); context.summary = { totalItems: this.option('summary.totalItems'), @@ -14,8 +14,8 @@ export class DataGridAIAssistantIntegrationController extends AIAssistantIntegra return context; } - protected getGridColumnExtraContext(column: Column): GridContext { - const context = super.getGridColumnExtraContext(column); + protected buildColumnContext(column: Column): GridContext { + const context = super.buildColumnContext(column); context.summary = { groupIndex: column.groupIndex, diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts index b33c7d3ab445..523f9cbf8c32 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/index.ts @@ -8,5 +8,4 @@ export const dataGridCommands = [ clearGroupingCommand, summaryCommand, clearSummaryCommand, - // TODO: try to remove "as GridCommand[]" ] as GridCommand[]; diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts index 2d878689ffdd..d76f646af331 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_integration_controller.ts @@ -4,7 +4,9 @@ import type { RequestCallbacks, } from '@js/common/ai-integration'; import errors from '@js/ui/widget/ui.errors'; +import type { ColumnsController } from '@ts/grids/grid_core/columns_controller/m_columns_controller'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type { DataController } from '@ts/grids/grid_core/data_controller/m_data_controller'; import { Controller } from '../m_modules'; import type { @@ -16,6 +18,10 @@ import type { export class AIAssistantIntegrationController extends Controller { private abort?: () => void; + private columnsController!: ColumnsController; + + private dataController!: DataController; + private getAICommandCallbacks( callbacks?: RequestCallbacks, ): RequestCallbacks { @@ -38,21 +44,17 @@ export class AIAssistantIntegrationController extends Controller { this.abort = undefined; } - private buildContext(): GridContext { - const dataController = this.getController('data'); - const gridExtraContext = this.getGridExtraContext(); - const keyExpr = this.option('keyExpr') ?? dataController.getDataSource()?.store()?.key(); - + protected buildContext(): GridContext { return { - keyExpr, + keyExpr: this.option('keyExpr') ?? this.dataController.getDataSource()?.store()?.key(), columns: this.buildColumnsContext(), filtering: { filterValue: this.option('filterValue'), }, paging: { - pageIndex: dataController.pageIndex(), - pageSize: dataController.pageSize(), - totalCount: dataController.totalCount(), + pageIndex: this.dataController.pageIndex(), + pageSize: this.dataController.pageSize(), + totalCount: this.dataController.totalCount(), }, search: { searchText: this.option('searchPanel.text') ?? '', @@ -62,46 +64,36 @@ export class AIAssistantIntegrationController extends Controller { mode: this.option('selection.mode'), selectAllMode: this.option('selection.selectAllMode'), }, - ...gridExtraContext, - } as GridContext; + }; } private buildColumnsContext(): GridContext[] { - const columnsController = this.getController('columns'); - const allColumns: Column[] = columnsController.getColumns(); + const allColumns: Column[] = this.columnsController.getColumns(); return allColumns .filter((column) => !column.command) - .map((column) => { - const gridColumnExtraContext = this.getGridColumnExtraContext(column); - - return ({ - dataField: column.dataField, - caption: column.caption, - dataType: column.dataType, - visible: column.visible !== false, - sortOrder: column.sortOrder, - sortIndex: column.sortIndex, - fixed: column.fixed, - fixedPosition: column.fixedPosition, - width: column.width, - visibleIndex: column.visibleIndex, - ...gridColumnExtraContext, - }); - }); - } - - protected getGridExtraContext(): GridContext { - return {}; + .map((column) => this.buildColumnContext(column)); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected getGridColumnExtraContext(column: Column): GridContext { - return {}; + protected buildColumnContext(column: Column): GridContext { + return ({ + dataField: column.dataField, + caption: column.caption, + dataType: column.dataType, + visible: column.visible !== false, + sortOrder: column.sortOrder, + sortIndex: column.sortIndex, + fixed: column.fixed, + fixedPosition: column.fixedPosition, + width: column.width, + visibleIndex: column.visibleIndex, + }); } public init(): void { this.createAction('onAIAssistantRequestCreating'); + this.dataController = this.getController('data'); + this.columnsController = this.getController('columns'); } private getAIIntegration(): AIIntegration | null { diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts index 92c8af953ddb..2db7290654a2 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/index.ts @@ -53,5 +53,4 @@ export const coreCommands = [ selectByKeysCommand, clearSortingCommand, sortingCommand, - // TODO: try to remove "as GridCommand[]" ] as GridCommand[]; From c3c5ef3e035f1dc93706e1c07bc72fe4d77a7b04 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 14 May 2026 22:15:33 +0400 Subject: [PATCH 2/3] update tests --- .../__tests__/ai_assistant_controller.test.ts | 335 +++++++++++++++++ ...integration_controller.integration.test.ts | 347 ++++++++++++++++++ .../__tests__/ai_assistant_controller.test.ts | 308 +++++++++++++++- 3 files changed, 987 insertions(+), 3 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts new file mode 100644 index 000000000000..ff30a770b8d8 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_controller.test.ts @@ -0,0 +1,335 @@ +import { + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { ExecuteGridAssistantCommandResult } from '@js/common/ai-integration'; +import type { ArrayStore } from '@js/common/data'; +import type { Message } from '@js/ui/chat'; +import { coreCommands } from '@ts/grids/grid_core/ai_assistant/commands'; +import { + AI_ASSISTANT_AUTHOR, + AI_ASSISTANT_AUTHOR_ID, + MessageStatus, +} from '@ts/grids/grid_core/ai_assistant/const'; +import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; +import type { + AIAssistantRequestCallbacks, + AIMessage, + CommandResult, +} from '@ts/grids/grid_core/ai_assistant/types'; +import type { InternalGrid } from '@ts/grids/grid_core/m_types'; + +import { DataGridAIAssistantController } from '../ai_assistant_controller'; +import { DataGridAIAssistantIntegrationController } from '../ai_assistant_integration_controller'; +import { dataGridCommands } from '../commands/index'; + +jest.mock('@ts/grids/grid_core/ai_assistant/grid_commands'); +jest.mock('../ai_assistant_integration_controller'); + +const MockedGridCommands = GridCommands as jest.MockedClass; +const MockedDataGridAIAssistantIntegrationController = DataGridAIAssistantIntegrationController as + jest.MockedClass; + +let sendRequestCallbacks: AIAssistantRequestCallbacks = {}; + +const createController = ( + options: Record = {}, +): DataGridAIAssistantController => { + const mockComponent = { + _optionCache: {}, + _controllers: {}, + option: jest.fn((name?: string) => { + if (name === undefined) { + return options; + } + + return options[name]; + }), + _createActionByOption: jest.fn(() => jest.fn()), + }; + + const controller = new DataGridAIAssistantController( + mockComponent as unknown as InternalGrid, + ); + controller.init(); + + return controller; +}; + +const getStore = ( + controller: DataGridAIAssistantController, +): ArrayStore => { + const dataSource = controller.getMessageDataSource() as { + store: ArrayStore; + }; + return dataSource.store; +}; + +describe('DataGridAIAssistantController', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (MockedGridCommands.mockImplementation as jest.Mock).call( + MockedGridCommands, + () => ({ + validate: jest.fn().mockReturnValue(true), + executeCommands: jest.fn<() => Promise>() + .mockResolvedValue([{ status: 'success', message: 'sort' }]), + abort: jest.fn(), + buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }), + isExecuting: jest.fn().mockReturnValue(false), + }), + ); + + (MockedDataGridAIAssistantIntegrationController + .mockImplementation as jest.Mock).call( + MockedDataGridAIAssistantIntegrationController, + () => ({ + init: jest.fn(), + dispose: jest.fn(), + sendRequest: jest.fn(( + _text: string, + _responseSchema: unknown, + callbacks?: AIAssistantRequestCallbacks, + ) => { + sendRequestCallbacks = callbacks ?? {}; + }), + abortRequest: jest.fn(() => { + sendRequestCallbacks.onAbort?.(); + }), + isRequestAwaitingCompletion: jest.fn().mockReturnValue(false), + }), + ); + }); + + describe('getGridCommandList', () => { + it('should include all core commands', () => { + createController(); + + const constructorCall = MockedGridCommands.mock.calls[0]; + const commandList = constructorCall[1]; + const commandNames = commandList.map((c) => c.name); + + for (const coreCommand of coreCommands) { + expect(commandNames).toContain(coreCommand.name); + } + }); + + it('should include all data grid specific commands', () => { + createController(); + + const constructorCall = MockedGridCommands.mock.calls[0]; + const commandList = constructorCall[1]; + const commandNames = commandList.map((c) => c.name); + + expect(commandNames).toContain('grouping'); + expect(commandNames).toContain('clearGrouping'); + expect(commandNames).toContain('summary'); + expect(commandNames).toContain('clearSummary'); + }); + + it('should extend core commands with data grid commands', () => { + createController(); + + const constructorCall = MockedGridCommands.mock.calls[0]; + const commandList = constructorCall[1]; + + expect(commandList).toHaveLength( + coreCommands.length + dataGridCommands.length, + ); + }); + + it('should place core commands before data grid commands', () => { + createController(); + + const constructorCall = MockedGridCommands.mock.calls[0]; + const commandList = constructorCall[1]; + const commandNames = commandList.map((c) => c.name); + + const firstDataGridCommandIndex = commandNames.indexOf('grouping'); + const lastCoreCommandIndex = commandNames.indexOf( + coreCommands[coreCommands.length - 1].name, + ); + + expect(firstDataGridCommandIndex).toBeGreaterThan(lastCoreCommandIndex); + }); + }); + + describe('getAiAssistantIntegrationController', () => { + it('should create DataGridAIAssistantIntegrationController', () => { + createController(); + + expect(MockedDataGridAIAssistantIntegrationController).toHaveBeenCalledTimes(1); + }); + }); + + describe('inherited behavior', () => { + it('should return dataSource with store', () => { + const controller = createController(); + const dataSource = controller.getMessageDataSource() as { + store: ArrayStore; + }; + + expect(dataSource.store).toBeDefined(); + }); + + it('should create pending message in store', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Group by category', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + id: expect.stringContaining(AI_ASSISTANT_AUTHOR_ID), + author: AI_ASSISTANT_AUTHOR, + status: MessageStatus.Pending, + }), + ]); + }); + + it('should complete message as success when command succeed', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Group by category', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const actions = [{ name: 'grouping', args: { dataField: 'Category', groupIndex: 0 } }]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Success, + commands: [{ status: 'success', message: 'sort' }], + }), + ]); + }); + + it('should fail message when onError callback is called', async () => { + const controller = createController(); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Group by category', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + promise.catch(() => {}); + + sendRequestCallbacks.onError?.(new Error('Network error')); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Network error', + }), + ]); + + await expect(promise).rejects.toThrow('Network error'); + }); + + it('should abort request and fail message', async () => { + const controller = createController(); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Group by category', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + promise.catch(() => {}); + + controller.abortRequest(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + }), + ]); + + await expect(promise).rejects.toThrow(); + }); + + it('should call integration controller dispose on dispose', () => { + const controller = createController(); + + const integrationInstance = MockedDataGridAIAssistantIntegrationController + .mock.results[0].value as { dispose: jest.Mock }; + + controller.dispose(); + + expect(integrationInstance.dispose).toHaveBeenCalledTimes(1); + }); + + it('should reject second request while first is processing', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'First request', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const secondPromise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Second request', + timestamp: '2026-04-16T10:00:01.000Z', + } as Message); + secondPromise.catch(() => {}); + + await expect(secondPromise).rejects.toBeUndefined(); + }); + + it('should support regenerating failed AIMessage', async () => { + const controller = createController(); + + const aiMessage: AIMessage = { + id: 'assistant-123', + author: AI_ASSISTANT_AUTHOR, + text: MessageStatus.Failure, + prompt: 'Group by category', + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Network error', + }; + + const store = getStore(controller); + await store.insert(aiMessage); + + const promise = controller.sendRequestToAI(aiMessage); + + const actions = [{ name: 'grouping', args: { dataField: 'Category', groupIndex: 0 } }]; + sendRequestCallbacks.onComplete?.({ actions }); + + await promise; + + const messages = await store.load(); + + expect(messages).toEqual([ + expect.objectContaining({ + id: 'assistant-123', + status: MessageStatus.Success, + }), + ]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts new file mode 100644 index 000000000000..23d77a35d2ae --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts @@ -0,0 +1,347 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { + ExecuteGridAssistantCommandParams, + ExecuteGridAssistantCommandResult, + RequestCallbacks, +} from '@js/common/ai-integration'; +import type { Properties } from '@js/ui/data_grid'; +import errors from '@js/ui/widget/ui.errors'; +import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; +import { + afterTest, + beforeTest, + createDataGrid, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { GridContext, JsonSchema } from '@ts/grids/grid_core/ai_assistant/types'; + +import { DataGridAIAssistantIntegrationController } from '../ai_assistant_integration_controller'; + +const STUB_SCHEMA: JsonSchema = { type: 'object' }; + +interface SendRequestResult { + promise: Promise; + abort: () => void; +} + +const createMockAIIntegration = ( + onExecute?: ( + params: ExecuteGridAssistantCommandParams, + callbacks: RequestCallbacks, + ) => void, +): AIIntegration => { + const abortFn = jest.fn(); + + const integration = new AIIntegration({ + sendRequest(): SendRequestResult { + return { + promise: Promise.resolve('{}'), + abort: abortFn, + }; + }, + }); + + const originalExecute = integration.executeGridAssistant + .bind(integration); + + integration.executeGridAssistant = jest.fn(( + params: ExecuteGridAssistantCommandParams, + callbacks: RequestCallbacks, + ): (() => void) => { + if (onExecute) { + onExecute(params, callbacks); + return abortFn; + } + return originalExecute(params, callbacks); + }) as typeof integration.executeGridAssistant; + + return integration; +}; + +const createController = async ( + options: Record = {}, +): Promise => { + const { instance } = await createDataGrid({ + dataSource: [ + { id: 1, name: 'Name 1' }, + { id: 2, name: 'Name 2' }, + ], + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + { dataField: 'name', caption: 'Name', dataType: 'string' }, + ], + ...options, + // TODO: remove when d.ts is ready + } as unknown as Properties); + + const controller = new DataGridAIAssistantIntegrationController(instance); + controller.init(); + + return controller; +}; + +describe('DataGridAIAssistantIntegrationController', () => { + beforeEach(() => { + beforeTest(); + jest.spyOn(errors, 'log').mockImplementation(jest.fn()); + }); + + afterEach(() => { + afterTest(); + }); + + describe('sendRequest', () => { + it('should send request to aiIntegration', async () => { + const aiIntegration = createMockAIIntegration(); + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Group by category', STUB_SCHEMA); + + expect(aiIntegration.executeGridAssistant) + .toHaveBeenCalledTimes(1); + }); + + it('should log E1068 when aiIntegration is not set', async () => { + const controller = await createController({}); + + controller.sendRequest('Group by category', STUB_SCHEMA); + + expect(errors.log).toHaveBeenCalledWith('E1068'); + }); + }); + + describe('context building', () => { + it('should include summary in context', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + summary: { + totalItems: [{ column: 'id', summaryType: 'count' }], + groupItems: [{ column: 'name', summaryType: 'count' }], + }, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const summary = context.summary as GridContext; + + expect(summary).toBeDefined(); + expect(summary.totalItems).toEqual([{ column: 'id', summaryType: 'count' }]); + expect(summary.groupItems).toEqual([{ column: 'name', summaryType: 'count' }]); + }); + + it('should include summary with undefined totalItems when not set', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const summary = context.summary as GridContext; + + expect(summary).toBeDefined(); + expect(summary.totalItems).toBeUndefined(); + expect(summary.groupItems).toBeUndefined(); + }); + + it('should include groupIndex in column context', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + columns: [ + { + dataField: 'id', caption: 'ID', dataType: 'number', + }, + { + dataField: 'name', caption: 'Name', dataType: 'string', groupIndex: 0, + }, + ], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; + + const nameColumn = columns.find((col) => col.dataField === 'name'); + const columnSummary = nameColumn?.summary as GridContext; + + expect(columnSummary).toBeDefined(); + expect(columnSummary.groupIndex).toBe(0); + }); + + it('should include undefined groupIndex when column is not grouped', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + ], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; + + const idColumn = columns.find((col) => col.dataField === 'id'); + const columnSummary = idColumn?.summary as GridContext; + + expect(columnSummary).toBeDefined(); + expect(columnSummary.groupIndex).toBeUndefined(); + }); + + it('should include base context properties alongside summary', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + paging: { pageSize: 10 }, + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + + expect(context.columns).toBeDefined(); + expect(context.paging).toBeDefined(); + expect(context.search).toBeDefined(); + expect(context.selection).toBeDefined(); + expect(context.filtering).toBeDefined(); + expect(context.summary).toBeDefined(); + }); + + it('should include base column context properties alongside summary', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + columns: [ + { + dataField: 'id', + caption: 'ID', + dataType: 'number', + sortOrder: 'asc', + sortIndex: 0, + }, + ], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; + const column = columns[0]; + + expect(column.dataField).toBe('id'); + expect(column.caption).toBe('ID'); + expect(column.dataType).toBe('number'); + expect(column.sortOrder).toBe('asc'); + expect(column.sortIndex).toBe(0); + expect(column.summary).toBeDefined(); + }); + + it('should exclude command columns', async () => { + let capturedParams: GridContext = {}; + const aiIntegration = createMockAIIntegration((params) => { + capturedParams = params as GridContext; + }); + + const controller = await createController({ + aiIntegration, + selection: { mode: 'multiple' }, + columns: [ + { dataField: 'id', caption: 'ID', dataType: 'number' }, + { dataField: 'name', caption: 'Name', dataType: 'string' }, + ], + }); + + controller.sendRequest('test', STUB_SCHEMA); + + const context = capturedParams.context as GridContext; + const columns = context.columns as GridContext[]; + + const hasCommandColumn = columns.some( + (col) => !col.dataField, + ); + expect(hasCommandColumn).toBe(false); + }); + }); + + describe('abortRequest', () => { + it('should abort in-progress request', async () => { + const abortSpy = jest.fn(); + const aiIntegration = createMockAIIntegration(); + + aiIntegration.executeGridAssistant = jest.fn( + (): (() => void) => abortSpy, + ) as typeof aiIntegration.executeGridAssistant; + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Group by category', STUB_SCHEMA); + expect(controller.isRequestAwaitingCompletion()).toBe(true); + + controller.abortRequest(); + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(controller.isRequestAwaitingCompletion()).toBe(false); + }); + }); + + describe('dispose', () => { + it('should abort request on dispose', async () => { + const abortSpy = jest.fn(); + const aiIntegration = createMockAIIntegration(); + + aiIntegration.executeGridAssistant = jest.fn( + (): (() => void) => abortSpy, + ) as typeof aiIntegration.executeGridAssistant; + + const controller = await createController({ + aiAssistant: { enabled: true, aiIntegration }, + }); + + controller.sendRequest('Group by category', STUB_SCHEMA); + expect(controller.isRequestAwaitingCompletion()).toBe(true); + + controller.dispose(); + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(controller.isRequestAwaitingCompletion()).toBe(false); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts index f6895075b8f2..5636c7580227 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_controller.test.ts @@ -283,6 +283,141 @@ describe('AIAssistantController', () => { await expect(promise).rejects.toThrow('Default error message'); }); + it('should fail message when response has empty actions array', async () => { + const controller = createController(); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + promise.catch(() => {}); + + sendRequestCallbacks.onComplete?.({ actions: [] }); + await Promise.resolve(); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Default error message', + }), + ]); + + await expect(promise).rejects.toThrow('Default error message'); + }); + + it('should fail message when validation fails', async () => { + (MockedGridCommands.mockImplementation as jest.Mock).call( + MockedGridCommands, + () => ({ + validate: jest.fn().mockReturnValue(false), + executeCommands: jest.fn<() => Promise>().mockResolvedValue([]), + abort: jest.fn(), + buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }), + isExecuting: jest.fn().mockReturnValue(false), + }), + ); + + const controller = createController(); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + promise.catch(() => {}); + + const actions = [{ name: 'sort', args: { column: 'Name' } }]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Received invalid commands', + }), + ]); + + await expect(promise).rejects.toThrow('Received invalid commands'); + }); + + it('should fail message when commands are already executing', async () => { + (MockedGridCommands.mockImplementation as jest.Mock).call( + MockedGridCommands, + () => ({ + validate: jest.fn().mockReturnValue(true), + executeCommands: jest.fn<() => Promise>().mockResolvedValue([]), + abort: jest.fn(), + buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }), + isExecuting: jest.fn().mockReturnValue(true), + }), + ); + + const controller = createController(); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + promise.catch(() => {}); + + const actions = [{ name: 'sort', args: { column: 'Name' } }]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Unexpected error', + }), + ]); + + await expect(promise).rejects.toThrow('Unexpected error'); + }); + + it('should fail message when buildResponseSchema returns falsy', async () => { + (MockedGridCommands.mockImplementation as jest.Mock).call( + MockedGridCommands, + () => ({ + validate: jest.fn().mockReturnValue(true), + executeCommands: jest.fn<() => Promise>().mockResolvedValue([]), + abort: jest.fn(), + buildResponseSchema: jest.fn().mockReturnValue(undefined), + isExecuting: jest.fn().mockReturnValue(false), + }), + ); + + const controller = createController(); + + const promise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Generate values', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + promise.catch(() => {}); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Grid commands not initialized', + }), + ]); + + await expect(promise).rejects.toThrow('Grid commands not initialized'); + }); + it('should resolve promise when command succeeds', async () => { const controller = createController(); @@ -326,7 +461,7 @@ describe('AIAssistantController', () => { await expect(promise).rejects.toThrow('Default error message'); }); - it('should ignore second request while first request is still processing', async () => { + it('should reject second request while first request is still processing', async () => { const controller = createController(); // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -336,11 +471,12 @@ describe('AIAssistantController', () => { timestamp: '2026-04-16T10:00:00.000Z', } as Message); - controller.sendRequestToAI({ + const secondPromise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, text: 'Second request', timestamp: '2026-04-16T10:00:01.000Z', - } as Message).catch(() => {}); + } as Message); + secondPromise.catch(() => {}); const messages = await getStore(controller).load(); @@ -350,6 +486,8 @@ describe('AIAssistantController', () => { sendRequest: jest.Mock; }; expect(integrationInstance.sendRequest).toHaveBeenCalledTimes(1); + + await expect(secondPromise).rejects.toBeUndefined(); }); it('should accept new request after previous request completes successfully', async () => { @@ -441,6 +579,131 @@ describe('AIAssistantController', () => { }; expect(integrationInstance.sendRequest).toHaveBeenCalledTimes(2); }); + + it('should use customizeResponseTitle when provided', async () => { + const customizeResponseTitle = jest.fn().mockReturnValue('Custom Title'); + + const controller = createController({ + 'aiAssistant.customizeResponseTitle': customizeResponseTitle, + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Sort by name', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const actions = [{ name: 'sort', args: { column: 'Name' } }]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(customizeResponseTitle).toHaveBeenCalledWith( + MessageStatus.Success, + ['sort'], + ); + expect(messages).toEqual([ + expect.objectContaining({ + headerText: 'Custom Title', + }), + ]); + }); + + it('should format headerText with "and" for multiple unique command names', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Sort and filter', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const actions = [ + { name: 'sorting', args: { column: 'Name' } }, + { name: 'filtering', args: { column: 'Age' } }, + ]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + headerText: 'Sorting and Filtering', + }), + ]); + }); + + it('should captionize single command name for headerText', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Sort by name', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const actions = [{ name: 'sorting', args: { column: 'Name' } }]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + headerText: 'Sorting', + }), + ]); + }); + + it('should deduplicate command names in headerText', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Sort multiple columns', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const actions = [ + { name: 'sorting', args: { column: 'Name' } }, + { name: 'sorting', args: { column: 'Age' } }, + ]; + sendRequestCallbacks.onComplete?.({ actions }); + await Promise.resolve(); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + headerText: 'Sorting', + }), + ]); + }); + + it('should store prompt from user message', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Sort by Name column', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const messages = await getStore(controller).load(); + + expect(messages).toEqual([ + expect.objectContaining({ + prompt: 'Sort by Name column', + }), + ]); + }); }); describe('abortRequest', () => { @@ -490,6 +753,19 @@ describe('AIAssistantController', () => { }); }); + describe('dispose', () => { + it('should call aiAssistantIntegrationController.dispose', () => { + const controller = createController(); + + const integrationInstance = MockedAIAssistantIntegrationController + .mock.results[0].value as { dispose: jest.Mock }; + + controller.dispose(); + + expect(integrationInstance.dispose).toHaveBeenCalledTimes(1); + }); + }); + describe('sendRequestToAI with AIMessage (regenerate)', () => { it('should reset message status to pending when AIMessage is passed', async () => { const controller = createController(); @@ -637,5 +913,31 @@ describe('AIAssistantController', () => { }), ]); }); + + it('should reject regeneration while another request is processing', async () => { + const controller = createController(); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'First request', + timestamp: '2026-04-16T10:00:00.000Z', + } as Message); + + const aiMessage: AIMessage = { + id: 'assistant-old', + author: AI_ASSISTANT_AUTHOR, + text: MessageStatus.Failure, + prompt: 'Old request', + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Network error', + }; + + const regeneratePromise = controller.sendRequestToAI(aiMessage); + regeneratePromise.catch(() => {}); + + await expect(regeneratePromise).rejects.toBeUndefined(); + }); }); }); From 3b39e233a687d2f14aab679a924d0366c09617c9 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 14 May 2026 22:50:43 +0400 Subject: [PATCH 3/3] remove TODO --- .../ai_assistant_integration_controller.integration.test.ts | 3 +-- .../ai_assistant_integration_controller.integration.test.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts index 23d77a35d2ae..43cdcc695dce 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts @@ -77,8 +77,7 @@ const createController = async ( { dataField: 'name', caption: 'Name', dataType: 'string' }, ], ...options, - // TODO: remove when d.ts is ready - } as unknown as Properties); + } as Properties); const controller = new DataGridAIAssistantIntegrationController(instance); controller.init(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts index 72af1f8a0778..fa5ee6123755 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/ai_assistant_integration_controller.integration.test.ts @@ -78,8 +78,7 @@ const createController = async ( { dataField: 'name', caption: 'Name', dataType: 'string' }, ], ...options, - // TODO: remove when d.ts is ready - } as unknown as Properties); + } as Properties); const controller = new AIAssistantIntegrationController(instance); controller.init();