From 3ab202292e9dc0a915e8060762061907606d065d Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 21 May 2026 21:22:41 +0400 Subject: [PATCH 01/11] rename error message const --- .../grids/grid_core/ai_assistant/ai_assistant_controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts index c61f3d03553b..61aa07f8ea43 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/ai_assistant_controller.ts @@ -90,10 +90,10 @@ export class AIAssistantController extends Controller { } const customizeResponseText = this.option('aiAssistant.customizeResponseText'); - const notInitializedErrorMsg = messageLocalization.format('dxDataGrid-aiAssistantUnexpectedErrorMessage'); + const localizedErrorMsg = messageLocalization.format('dxDataGrid-aiAssistantUnexpectedErrorMessage'); return this.gridCommands?.executeCommands(response.actions, customizeResponseText) - ?? Promise.reject(new Error(notInitializedErrorMsg)); + ?? Promise.reject(new Error(localizedErrorMsg)); } private createPendingAIMessage(message: Message): AIMessage { From 703e84586edfc4b15752cb4bfba5577172ca9b43 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 21 May 2026 21:22:59 +0400 Subject: [PATCH 02/11] first iteration --- .../error_handling.integration.test.ts | 916 ++++++++++++++++++ 1 file changed, 916 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts new file mode 100644 index 000000000000..ebd654825687 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -0,0 +1,916 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { + ExecuteGridAssistantCommandParams, + ExecuteGridAssistantCommandResult, + RequestCallbacks, + Response as SendRequestResult, +} from '@js/common/ai-integration'; +import type { ArrayStore } from '@js/common/data'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { Message } from '@js/ui/chat'; +import errors from '@js/ui/widget/ui.errors'; +import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; +import CustomStore from '@ts/data/m_custom_store'; +import { + afterTest, + beforeTest, + createDataGrid, + type DataGridInstance, + flushAsync, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; +import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; +import type { AIMessage, CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; +import { CLASSES } from '@ts/grids/grid_core/ai_chat/const'; + +interface MockAIIntegrationResult { + aiIntegration: AIIntegration; + getLastCallbacks: () => RequestCallbacks; + getAbortSpy: () => jest.Mock; +} + +const createMockAIIntegration = (): MockAIIntegrationResult => { + let lastCallbacks: RequestCallbacks = {}; + const abortSpy = jest.fn(); + + const aiIntegration = new AIIntegration({ + sendRequest(): SendRequestResult { + return { + promise: Promise.resolve('{}'), + abort: jest.fn(), + }; + }, + }); + + aiIntegration.executeGridAssistant = jest.fn(( + _params: ExecuteGridAssistantCommandParams, + callbacks: RequestCallbacks, + ): (() => void) => { + lastCallbacks = callbacks; + return abortSpy; + }) as typeof aiIntegration.executeGridAssistant; + + return { + aiIntegration, + getLastCallbacks: () => lastCallbacks, + getAbortSpy: () => abortSpy, + }; +}; + +const LOCAL_DATA = [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, +]; + +const DEFAULT_COLUMNS = [ + { dataField: 'id', caption: 'ID', dataType: 'number' as const }, + { dataField: 'name', caption: 'Name', dataType: 'string' as const }, +]; + +const createDataGridWithAi = async ( + overrides: Record = {}, +): Promise<{ + instance: DataGridInstance; + getLastCallbacks: () => RequestCallbacks; + getAbortSpy: () => jest.Mock; +}> => { + const { aiIntegration, getLastCallbacks, getAbortSpy } = createMockAIIntegration(); + + const { instance } = await createDataGrid({ + dataSource: LOCAL_DATA, + columns: DEFAULT_COLUMNS, + aiAssistant: { enabled: true, aiIntegration, title: 'AI Assistant' }, + ...overrides, + }); + + return { instance, getLastCallbacks, getAbortSpy }; +}; + +const createDataGridWithAiAndPopup = async ( + overrides: Record = {}, +): Promise<{ + instance: DataGridInstance; + getLastCallbacks: () => RequestCallbacks; + getAbortSpy: () => jest.Mock; +}> => { + const result = await createDataGridWithAi(overrides); + + const viewController = result.instance.getController('aiAssistantViewController'); + await viewController.toggle(); + jest.runAllTimers(); + + return result; +}; + +const sendAiRequest = ( + instance: DataGridInstance, + text: string, +): void => { + const controller = instance.getController('aiAssistant'); + + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text, + timestamp: new Date().toISOString(), + } as Message).catch(() => {}); + jest.runAllTimers(); +}; + +const getMessageStore = ( + instance: DataGridInstance, +): ArrayStore => { + const controller = instance.getController('aiAssistant'); + return controller.getMessageStore(); +}; + +const loadMessages = ( + instance: DataGridInstance, +): Promise => getMessageStore(instance).load() as Promise; + +const findMessageElements = (): dxElementWrapper => $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`); + +const getMessageStatusClass = ($message: dxElementWrapper): string => { + if ($message.hasClass(CLASSES.messagePending)) return MessageStatus.Pending; + if ($message.hasClass(CLASSES.messageSuccess)) return MessageStatus.Success; + if ($message.hasClass(CLASSES.messageError)) return MessageStatus.Failure; + return ''; +}; + +describe('AI Assistant error handling', () => { + // eslint-disable-next-line @typescript-eslint/init-declarations + let validateSpy: ReturnType; + // eslint-disable-next-line @typescript-eslint/init-declarations + let executeCommandsSpy: ReturnType; + // eslint-disable-next-line @typescript-eslint/init-declarations + let buildResponseSchemaSpy: ReturnType; + + beforeEach(() => { + beforeTest(); + jest.spyOn(errors, 'log').mockImplementation(jest.fn()); + + validateSpy = jest.spyOn(GridCommands.prototype, 'validate') + .mockReturnValue(true); + executeCommandsSpy = jest.spyOn(GridCommands.prototype, 'executeCommands') + .mockResolvedValue([ + { status: 'success', message: 'Done' }, + ] as CommandResult[]); + buildResponseSchemaSpy = jest.spyOn(GridCommands.prototype, 'buildResponseSchema') + .mockReturnValue({ type: 'object' }); + }); + + afterEach(() => { + validateSpy.mockRestore(); + executeCommandsSpy.mockRestore(); + buildResponseSchemaSpy.mockRestore(); + afterTest(); + }); + + describe('no aiIntegration configured', () => { + it('should fail message and log E1068 when aiIntegration is missing', async () => { + const { instance } = await createDataGridWithAiAndPopup({ + aiAssistant: { enabled: true, title: 'AI Assistant' }, + }); + + sendAiRequest(instance, 'Sort by name'); + + const messages = await loadMessages(instance); + + expect(messages).toHaveLength(1); + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Invalid response from the AI service. Please try again.', + }), + ]); + expect(errors.log).toHaveBeenCalledWith('E1068'); + }); + }); + + describe('network / API error', () => { + it('should render failure message with correct headerText and errorText', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + getLastCallbacks().onError?.(new Error('Network error')); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Invalid response from the AI service. Please try again.', + text: MessageStatus.Failure, + }), + ]); + + const $messages = findMessageElements(); + + expect($messages.length).toBe(1); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) + .toBe('Invalid response from the AI service. Please try again.'); + }); + }); + + describe('invalid response', () => { + it('should fail when response has no actions property', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + getLastCallbacks().onComplete?.({} as ExecuteGridAssistantCommandResult); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Invalid response from the AI service. Please try again.', + }), + ]); + }); + + it('should fail when response has empty actions array', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + getLastCallbacks().onComplete?.({ actions: [] }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Invalid response from the AI service. Please try again.', + }), + ]); + }); + + it('should fail when response actions is not an array', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + getLastCallbacks().onComplete?.( + { actions: 'invalid' } as unknown as ExecuteGridAssistantCommandResult, + ); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Invalid response from the AI service. Please try again.', + }), + ]); + }); + }); + + describe('validation failure', () => { + it('should fail when command validation returns false', async () => { + validateSpy.mockReturnValue(false); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + const actions = [{ name: 'sort', args: { column: 'Name' } }]; + getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Invalid response from the AI service. Please try again.', + }), + ]); + }); + }); + + describe('execution already in progress', () => { + it('should fail when commands are already executing', async () => { + const isExecutingSpy = jest.spyOn(GridCommands.prototype, 'isExecuting') + .mockReturnValue(true); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + const actions = [{ name: 'sort', args: { column: 'Name' } }]; + getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Execution already in progress. Please wait.', + }), + ]); + + isExecutingSpy.mockRestore(); + }); + }); + + describe('schema build failure', () => { + it('should fail when buildResponseSchema returns undefined', async () => { + buildResponseSchemaSpy.mockReturnValue(undefined as never); + + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'An unexpected error occurred. Please try again.', + }), + ]); + }); + }); + + describe('request abort', () => { + it('should fail message with abort text when request is aborted', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + const controller = instance.getController('aiAssistant'); + controller.abortRequest(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Request stopped.', + }), + ]); + }); + + it('should render abort message in the DOM', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + const controller = instance.getController('aiAssistant'); + controller.abortRequest(); + await flushAsync(); + + const $messages = findMessageElements(); + + expect($messages.length).toBe(1); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) + .toBe('Request stopped.'); + }); + }); + + describe('concurrent request rejection', () => { + it('should reject second request while first is processing', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'First request'); + + const controller = instance.getController('aiAssistant'); + + const secondPromise = controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text: 'Second request', + timestamp: new Date().toISOString(), + } as Message); + secondPromise.catch(() => {}); + + await expect(secondPromise) + .rejects.toThrow('Request already in progress. Please wait.'); + + const messages = await loadMessages(instance); + + expect(messages).toHaveLength(1); + }); + }); + + describe('request cancelled via onAIAssistantRequestCreating', () => { + it('should fail message when cancel is set to true', async () => { + const { instance } = await createDataGridWithAiAndPopup({ + onAIAssistantRequestCreating: (e: { cancel: boolean }): void => { + e.cancel = true; + }, + }); + + sendAiRequest(instance, 'Sort by name'); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Request stopped.', + }), + ]); + }); + }); + + describe('partial command failure', () => { + it('should set failure status when some commands fail', async () => { + executeCommandsSpy.mockResolvedValue([ + { status: 'success', message: 'Sorted by Name' }, + { status: 'failure', message: 'Failed to filter' }, + ] as CommandResult[]); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort and filter'); + + const actions = [ + { name: 'sorting', args: { column: 'Name' } }, + { name: 'filtering', args: { column: 'Age' } }, + ]; + getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + commands: [ + { status: 'success', message: 'Sorted by Name' }, + { status: 'failure', message: 'Failed to filter' }, + ], + }), + ]); + + const $messages = findMessageElements(); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + }); + + it('should set failure status when commands contain aborted items', async () => { + executeCommandsSpy.mockResolvedValue([ + { status: 'success', message: 'Sorted by Name' }, + { status: 'aborted', message: 'Execution Interrupted' }, + ] as CommandResult[]); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort and group'); + + const actions = [ + { name: 'sorting', args: { column: 'Name' } }, + { name: 'grouping', args: { column: 'Name' } }, + ]; + getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + commands: [ + { status: 'success', message: 'Sorted by Name' }, + { status: 'aborted', message: 'Execution Interrupted' }, + ], + }), + ]); + }); + }); + + describe('successful response', () => { + it('should set success status with correct headerText and commands', async () => { + executeCommandsSpy.mockResolvedValue([ + { status: 'success', message: 'Sorted by Name ascending' }, + ] as CommandResult[]); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by Name'); + + const actions = [{ name: 'sorting', args: { column: 'Name' } }]; + getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Success, + headerText: 'Sorting', + commands: [{ status: 'success', message: 'Sorted by Name ascending' }], + }), + ]); + + const $messages = findMessageElements(); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); + expect($messages.eq(0).find(`.${CLASSES.actionListItemText}`).text()) + .toBe('Sorted by Name ascending'); + }); + + it('should format headerText with "and" for multiple command names', async () => { + executeCommandsSpy.mockResolvedValue([ + { status: 'success', message: 'Sorted' }, + { status: 'success', message: 'Filtered' }, + ] as CommandResult[]); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort and filter'); + + const actions = [ + { name: 'sorting', args: { column: 'Name' } }, + { name: 'filtering', args: { column: 'Age' } }, + ]; + getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + headerText: 'Sorting and Filtering', + }), + ]); + }); + }); + + describe('delayed response', () => { + it('should show pending status before response arrives', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + const messagesBefore = await loadMessages(instance); + + expect(messagesBefore).toEqual([ + expect.objectContaining({ + status: MessageStatus.Pending, + headerText: 'Request in progress', + }), + ]); + + const $messages = findMessageElements(); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); + }); + + it('should transition from pending to success after delayed response', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + let messages = await loadMessages(instance); + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Pending, + })); + + getLastCallbacks().onComplete?.({ + actions: [{ name: 'sorting', args: { column: 'Name' } }], + }); + await flushAsync(); + await flushAsync(); + + messages = await loadMessages(instance); + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Success, + })); + }); + + it('should transition from pending to failure after delayed error', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + let messages = await loadMessages(instance); + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Pending, + })); + + getLastCallbacks().onError?.(new Error('Timeout')); + await flushAsync(); + + messages = await loadMessages(instance); + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Failure, + })); + }); + }); + + describe('chat closed during processing', () => { + it('should abort request and show failure after closing chat with confirm', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by name'); + + expect(findMessageElements().length).toBe(1); + expect(getMessageStatusClass(findMessageElements().eq(0))).toBe(MessageStatus.Pending); + + const viewController = instance.getController('aiAssistantViewController'); + + await viewController.toggle().catch(() => {}); + jest.runAllTimers(); + await flushAsync(); + + const confirmDialogSelector = '.dx-datagrid-ai-assistant-confirm-dialog'; + const yesButton = document.querySelectorAll( + `${confirmDialogSelector} .dx-button`, + )[1] as HTMLElement; + yesButton.click(); + jest.runAllTimers(); + await flushAsync(); + + await viewController.toggle(); + jest.runAllTimers(); + await flushAsync(); + + const $messages = findMessageElements(); + + expect($messages.length).toBe(1); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) + .toBe('Request stopped.'); + }); + }); + + describe('regeneration after failure', () => { + it('should reset to pending and then succeed after regeneration', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'Sort by Name'); + getLastCallbacks().onError?.(new Error('Network error')); + await flushAsync(); + + let $messages = findMessageElements(); + expect($messages.length).toBe(1); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + + const regenerateButton = $messages.eq(0) + .find(`.${CLASSES.messageRegenerateButton}`).get(0) as HTMLElement; + expect(regenerateButton).toBeTruthy(); + + regenerateButton.click(); + jest.runAllTimers(); + await flushAsync(); + + $messages = findMessageElements(); + expect($messages.length).toBe(1); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); + + getLastCallbacks().onComplete?.({ + actions: [{ name: 'sorting', args: { column: 'Name' } }], + }); + await flushAsync(); + await flushAsync(); + + $messages = findMessageElements(); + expect($messages.length).toBe(1); + expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); + }); + + it('should reject regeneration while another request is processing', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + sendAiRequest(instance, 'First request'); + + const controller = instance.getController('aiAssistant'); + const store = controller.getMessageStore(); + + const aiMessage: AIMessage = { + id: 'assistant-old', + author: { id: 'assistant' }, + text: MessageStatus.Failure, + prompt: 'Old request', + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Network error', + }; + + await store.insert(aiMessage); + + const regeneratePromise = controller.sendRequestToAI(aiMessage); + regeneratePromise.catch(() => {}); + + await expect(regeneratePromise) + .rejects.toThrow('Request already in progress. Please wait.'); + }); + }); + + describe('customizeResponseTitle edge cases', () => { + it('should handle customizeResponseTitle returning empty string', async () => { + const mockIntegration = createMockAIIntegration(); + + const { instance } = await createDataGridWithAiAndPopup({ + aiAssistant: { + enabled: true, + aiIntegration: mockIntegration.aiIntegration, + title: 'AI Assistant', + customizeResponseTitle: (): string => '', + }, + }); + + sendAiRequest(instance, 'Sort by name'); + + const actions = [{ name: 'sorting', args: { column: 'Name' } }]; + mockIntegration.getLastCallbacks().onComplete?.({ actions }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Success, + headerText: '', + }), + ]); + }); + }); + + describe('dispose during processing', () => { + it('should abort request and mark message as failure on dispose', async () => { + const { instance } = await createDataGridWithAiAndPopup(); + + const controller = instance.getController('aiAssistant'); + + sendAiRequest(instance, 'Sort by name'); + + const messagesBefore = await loadMessages(instance); + expect(messagesBefore[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Pending, + })); + + controller.dispose(); + await flushAsync(); + + const messagesAfter = await loadMessages(instance); + expect(messagesAfter[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Request stopped.', + })); + }); + }); + + describe('resilience after consecutive failures', () => { + it('should process request successfully after multiple prior failures', async () => { + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const controller = instance.getController('aiAssistant'); + + sendAiRequest(instance, 'Request 1'); + getLastCallbacks().onError?.(new Error('error 1')); + await flushAsync(); + + sendAiRequest(instance, 'Request 2'); + getLastCallbacks().onComplete?.({} as ExecuteGridAssistantCommandResult); + await flushAsync(); + await flushAsync(); + + sendAiRequest(instance, 'Request 3'); + controller.abortRequest(); + await flushAsync(); + + sendAiRequest(instance, 'Request 4'); + getLastCallbacks().onComplete?.({ + actions: [{ name: 'sorting', args: { column: 'Name' } }], + }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toHaveLength(4); + + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Failure, + })); + expect(messages[1]).toEqual(expect.objectContaining({ + status: MessageStatus.Failure, + })); + expect(messages[2]).toEqual(expect.objectContaining({ + status: MessageStatus.Failure, + })); + expect(messages[3]).toEqual(expect.objectContaining({ + status: MessageStatus.Success, + commands: [{ status: 'success', message: 'Done' }], + })); + }); + }); + + describe('with remote data source', () => { + const createRemoteDataSource = ( + data: Record[], + delay = 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => new CustomStore({ + key: 'id', + load: () => new Promise((resolve) => { + // eslint-disable-next-line no-restricted-globals + setTimeout(() => resolve(data), delay); + }), + }); + + it('should handle error response identically with remote data', async () => { + const remoteStore = createRemoteDataSource(LOCAL_DATA); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup({ + dataSource: remoteStore, + }); + + sendAiRequest(instance, 'Sort by name'); + + getLastCallbacks().onError?.(new Error('Remote network error')); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Failure, + headerText: 'Failed to process request', + errorText: 'Invalid response from the AI service. Please try again.', + }), + ]); + }); + + it('should handle success response identically with remote data', async () => { + const remoteStore = createRemoteDataSource(LOCAL_DATA); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup({ + dataSource: remoteStore, + }); + + sendAiRequest(instance, 'Sort by name'); + + getLastCallbacks().onComplete?.({ + actions: [{ name: 'sorting', args: { column: 'Name' } }], + }); + await flushAsync(); + await flushAsync(); + + const messages = await loadMessages(instance); + + expect(messages).toEqual([ + expect.objectContaining({ + status: MessageStatus.Success, + }), + ]); + }); + + it('should handle delayed remote data load with error response', async () => { + const remoteStore = createRemoteDataSource(LOCAL_DATA, 100); + + const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup({ + dataSource: remoteStore, + }); + + sendAiRequest(instance, 'Sort by name'); + + let messages = await loadMessages(instance); + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Pending, + })); + + getLastCallbacks().onComplete?.({ actions: [] }); + await flushAsync(); + await flushAsync(); + + messages = await loadMessages(instance); + + expect(messages[0]).toEqual(expect.objectContaining({ + status: MessageStatus.Failure, + errorText: 'Invalid response from the AI service. Please try again.', + })); + }); + }); +}); From ca4cf2852bd5d33517d05dda973521d8a587521e Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 21 May 2026 23:55:04 +0400 Subject: [PATCH 03/11] add AIAssistantDataGridModel --- .../__tests__/__mock__/model/ai_assistant.ts | 53 +++ .../__tests__/__mock__/model/data_grid.ts | 5 +- .../grids/data_grid/__tests__/types.ts | 6 + .../error_handling.integration.test.ts | 340 +++++++++--------- 4 files changed, 229 insertions(+), 175 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts create mode 100644 packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts new file mode 100644 index 000000000000..f04f302246c8 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -0,0 +1,53 @@ +import type { ArrayStore } from '@js/common/data'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { Message } from '@js/ui/chat'; +import type { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; +import type { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view_controller'; +import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; +import { CLASSES } from '@ts/grids/grid_core/ai_chat/const'; + +import { DataGridModel } from './data_grid'; + +export class AIAssistantDataGridModel extends DataGridModel { + public getAiAssistantController(): AIAssistantController { + return this.getInstance().getController('aiAssistant'); + } + + public getAiAssistantViewController(): AIAssistantViewController { + return this.getInstance().getController('aiAssistantViewController'); + } + + public sendAiRequest(text: string): void { + const controller = this.getAiAssistantController(); + + controller.sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text, + timestamp: new Date().toISOString(), + } as Message).catch(() => {}); + } + + public getMessageStore(): ArrayStore { + return this.getAiAssistantController().getMessageStore(); + } + + public loadMessages(): Promise { + return this.getMessageStore().load() as Promise; + } + + public findMessageElements(): dxElementWrapper { + return $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`); + } + + public getMessageStatusClass($message: dxElementWrapper): string { + if ($message.hasClass(CLASSES.messagePending)) return MessageStatus.Pending; + if ($message.hasClass(CLASSES.messageSuccess)) return MessageStatus.Success; + if ($message.hasClass(CLASSES.messageError)) return MessageStatus.Failure; + return ''; + } + + public async togglePopup(): Promise { + await this.getAiAssistantViewController().toggle(); + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts index 4c6cf1853d55..54477f2f44a1 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts @@ -1,12 +1,13 @@ import type { Column } from '@js/ui/data_grid'; import DataGrid from '@js/ui/data_grid'; +import type { DataGridWithControllers } from '@ts/grids/data_grid/__tests__/types'; import { DataGridBaseModel } from '@ts/grids/grid_core/__tests__/__mock__/model/data_grid_base'; export class DataGridModel extends DataGridBaseModel { protected NAME = 'dxDataGrid'; - public getInstance(): DataGrid { - return DataGrid.getInstance(this.root) as DataGrid; + public getInstance(): DataGridWithControllers { + return DataGrid.getInstance(this.root) as DataGridWithControllers; } public apiGetVisibleColumns(headerLevel?: number): Column[] { diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts new file mode 100644 index 000000000000..b2514cfd673b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts @@ -0,0 +1,6 @@ +import type DataGrid from '@js/ui/data_grid'; +import type { Controllers } from '@ts/grids/grid_core/m_types'; + +export interface DataGridWithControllers extends DataGrid { + getController: (name: T) => Controllers[T]; +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index ebd654825687..6e70ab57285a 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -12,18 +12,15 @@ import type { RequestCallbacks, Response as SendRequestResult, } from '@js/common/ai-integration'; -import type { ArrayStore } from '@js/common/data'; -import type { dxElementWrapper } from '@js/core/renderer'; -import $ from '@js/core/renderer'; import type { Message } from '@js/ui/chat'; import errors from '@js/ui/widget/ui.errors'; import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; import CustomStore from '@ts/data/m_custom_store'; +import { AIAssistantDataGridModel } from '@ts/grids/data_grid/__tests__/__mock__/model/ai_assistant'; import { afterTest, beforeTest, createDataGrid, - type DataGridInstance, flushAsync, } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; @@ -37,6 +34,16 @@ interface MockAIIntegrationResult { getAbortSpy: () => jest.Mock; } +const LOCAL_DATA = [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, +]; + +const DEFAULT_COLUMNS = [ + { dataField: 'id', caption: 'ID', dataType: 'number' as const }, + { dataField: 'name', caption: 'Name', dataType: 'string' as const }, +]; + const createMockAIIntegration = (): MockAIIntegrationResult => { let lastCallbacks: RequestCallbacks = {}; const abortSpy = jest.fn(); @@ -65,85 +72,44 @@ const createMockAIIntegration = (): MockAIIntegrationResult => { }; }; -const LOCAL_DATA = [ - { id: 1, name: 'Alpha' }, - { id: 2, name: 'Beta' }, -]; - -const DEFAULT_COLUMNS = [ - { dataField: 'id', caption: 'ID', dataType: 'number' as const }, - { dataField: 'name', caption: 'Name', dataType: 'string' as const }, -]; - const createDataGridWithAi = async ( overrides: Record = {}, ): Promise<{ - instance: DataGridInstance; + model: AIAssistantDataGridModel; getLastCallbacks: () => RequestCallbacks; getAbortSpy: () => jest.Mock; }> => { const { aiIntegration, getLastCallbacks, getAbortSpy } = createMockAIIntegration(); - const { instance } = await createDataGrid({ + await createDataGrid({ dataSource: LOCAL_DATA, columns: DEFAULT_COLUMNS, aiAssistant: { enabled: true, aiIntegration, title: 'AI Assistant' }, ...overrides, }); - return { instance, getLastCallbacks, getAbortSpy }; + const model = new AIAssistantDataGridModel( + document.getElementById('gridContainer') as HTMLElement, + ); + + return { model, getLastCallbacks, getAbortSpy }; }; const createDataGridWithAiAndPopup = async ( overrides: Record = {}, ): Promise<{ - instance: DataGridInstance; + model: AIAssistantDataGridModel; getLastCallbacks: () => RequestCallbacks; getAbortSpy: () => jest.Mock; }> => { const result = await createDataGridWithAi(overrides); - const viewController = result.instance.getController('aiAssistantViewController'); - await viewController.toggle(); + await result.model.togglePopup(); jest.runAllTimers(); return result; }; -const sendAiRequest = ( - instance: DataGridInstance, - text: string, -): void => { - const controller = instance.getController('aiAssistant'); - - controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text, - timestamp: new Date().toISOString(), - } as Message).catch(() => {}); - jest.runAllTimers(); -}; - -const getMessageStore = ( - instance: DataGridInstance, -): ArrayStore => { - const controller = instance.getController('aiAssistant'); - return controller.getMessageStore(); -}; - -const loadMessages = ( - instance: DataGridInstance, -): Promise => getMessageStore(instance).load() as Promise; - -const findMessageElements = (): dxElementWrapper => $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`); - -const getMessageStatusClass = ($message: dxElementWrapper): string => { - if ($message.hasClass(CLASSES.messagePending)) return MessageStatus.Pending; - if ($message.hasClass(CLASSES.messageSuccess)) return MessageStatus.Success; - if ($message.hasClass(CLASSES.messageError)) return MessageStatus.Failure; - return ''; -}; - describe('AI Assistant error handling', () => { // eslint-disable-next-line @typescript-eslint/init-declarations let validateSpy: ReturnType; @@ -175,13 +141,14 @@ describe('AI Assistant error handling', () => { describe('no aiIntegration configured', () => { it('should fail message and log E1068 when aiIntegration is missing', async () => { - const { instance } = await createDataGridWithAiAndPopup({ + const { model } = await createDataGridWithAiAndPopup({ aiAssistant: { enabled: true, title: 'AI Assistant' }, }); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toHaveLength(1); expect(messages).toEqual([ @@ -196,14 +163,15 @@ describe('AI Assistant error handling', () => { describe('network / API error', () => { it('should render failure message with correct headerText and errorText', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); getLastCallbacks().onError?.(new Error('Network error')); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -214,10 +182,10 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = findMessageElements(); + const $messages = model.findMessageElements(); expect($messages.length).toBe(1); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) .toBe('Invalid response from the AI service. Please try again.'); }); @@ -225,15 +193,16 @@ describe('AI Assistant error handling', () => { describe('invalid response', () => { it('should fail when response has no actions property', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); getLastCallbacks().onComplete?.({} as ExecuteGridAssistantCommandResult); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -245,15 +214,16 @@ describe('AI Assistant error handling', () => { }); it('should fail when response has empty actions array', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); getLastCallbacks().onComplete?.({ actions: [] }); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -264,9 +234,10 @@ describe('AI Assistant error handling', () => { }); it('should fail when response actions is not an array', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); getLastCallbacks().onComplete?.( { actions: 'invalid' } as unknown as ExecuteGridAssistantCommandResult, @@ -274,7 +245,7 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -289,16 +260,17 @@ describe('AI Assistant error handling', () => { it('should fail when command validation returns false', async () => { validateSpy.mockReturnValue(false); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); const actions = [{ name: 'sort', args: { column: 'Name' } }]; getLastCallbacks().onComplete?.({ actions }); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -314,16 +286,17 @@ describe('AI Assistant error handling', () => { const isExecutingSpy = jest.spyOn(GridCommands.prototype, 'isExecuting') .mockReturnValue(true); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); const actions = [{ name: 'sort', args: { column: 'Name' } }]; getLastCallbacks().onComplete?.({ actions }); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -340,12 +313,13 @@ describe('AI Assistant error handling', () => { it('should fail when buildResponseSchema returns undefined', async () => { buildResponseSchemaSpy.mockReturnValue(undefined as never); - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -358,15 +332,15 @@ describe('AI Assistant error handling', () => { describe('request abort', () => { it('should fail message with abort text when request is aborted', async () => { - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - const controller = instance.getController('aiAssistant'); - controller.abortRequest(); + model.getAiAssistantController().abortRequest(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -378,18 +352,18 @@ describe('AI Assistant error handling', () => { }); it('should render abort message in the DOM', async () => { - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - const controller = instance.getController('aiAssistant'); - controller.abortRequest(); + model.getAiAssistantController().abortRequest(); await flushAsync(); - const $messages = findMessageElements(); + const $messages = model.findMessageElements(); expect($messages.length).toBe(1); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) .toBe('Request stopped.'); }); @@ -397,11 +371,12 @@ describe('AI Assistant error handling', () => { describe('concurrent request rejection', () => { it('should reject second request while first is processing', async () => { - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'First request'); + model.sendAiRequest('First request'); + jest.runAllTimers(); - const controller = instance.getController('aiAssistant'); + const controller = model.getAiAssistantController(); const secondPromise = controller.sendRequestToAI({ author: { id: 'user', name: 'User' }, @@ -413,7 +388,7 @@ describe('AI Assistant error handling', () => { await expect(secondPromise) .rejects.toThrow('Request already in progress. Please wait.'); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toHaveLength(1); }); @@ -421,16 +396,17 @@ describe('AI Assistant error handling', () => { describe('request cancelled via onAIAssistantRequestCreating', () => { it('should fail message when cancel is set to true', async () => { - const { instance } = await createDataGridWithAiAndPopup({ + const { model } = await createDataGridWithAiAndPopup({ onAIAssistantRequestCreating: (e: { cancel: boolean }): void => { e.cancel = true; }, }); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -448,9 +424,10 @@ describe('AI Assistant error handling', () => { { status: 'failure', message: 'Failed to filter' }, ] as CommandResult[]); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort and filter'); + model.sendAiRequest('Sort and filter'); + jest.runAllTimers(); const actions = [ { name: 'sorting', args: { column: 'Name' } }, @@ -460,7 +437,7 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -472,8 +449,8 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = findMessageElements(); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + const $messages = model.findMessageElements(); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); }); it('should set failure status when commands contain aborted items', async () => { @@ -482,9 +459,10 @@ describe('AI Assistant error handling', () => { { status: 'aborted', message: 'Execution Interrupted' }, ] as CommandResult[]); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort and group'); + model.sendAiRequest('Sort and group'); + jest.runAllTimers(); const actions = [ { name: 'sorting', args: { column: 'Name' } }, @@ -494,7 +472,7 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -514,16 +492,17 @@ describe('AI Assistant error handling', () => { { status: 'success', message: 'Sorted by Name ascending' }, ] as CommandResult[]); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by Name'); + model.sendAiRequest('Sort by Name'); + jest.runAllTimers(); const actions = [{ name: 'sorting', args: { column: 'Name' } }]; getLastCallbacks().onComplete?.({ actions }); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -533,8 +512,8 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = findMessageElements(); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); + const $messages = model.findMessageElements(); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); expect($messages.eq(0).find(`.${CLASSES.actionListItemText}`).text()) .toBe('Sorted by Name ascending'); }); @@ -545,9 +524,10 @@ describe('AI Assistant error handling', () => { { status: 'success', message: 'Filtered' }, ] as CommandResult[]); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort and filter'); + model.sendAiRequest('Sort and filter'); + jest.runAllTimers(); const actions = [ { name: 'sorting', args: { column: 'Name' } }, @@ -557,7 +537,7 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -569,11 +549,12 @@ describe('AI Assistant error handling', () => { describe('delayed response', () => { it('should show pending status before response arrives', async () => { - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - const messagesBefore = await loadMessages(instance); + const messagesBefore = await model.loadMessages(); expect(messagesBefore).toEqual([ expect.objectContaining({ @@ -582,16 +563,17 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = findMessageElements(); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); + const $messages = model.findMessageElements(); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); }); it('should transition from pending to success after delayed response', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - let messages = await loadMessages(instance); + let messages = await model.loadMessages(); expect(messages[0]).toEqual(expect.objectContaining({ status: MessageStatus.Pending, })); @@ -602,18 +584,19 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - messages = await loadMessages(instance); + messages = await model.loadMessages(); expect(messages[0]).toEqual(expect.objectContaining({ status: MessageStatus.Success, })); }); it('should transition from pending to failure after delayed error', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - let messages = await loadMessages(instance); + let messages = await model.loadMessages(); expect(messages[0]).toEqual(expect.objectContaining({ status: MessageStatus.Pending, })); @@ -621,7 +604,7 @@ describe('AI Assistant error handling', () => { getLastCallbacks().onError?.(new Error('Timeout')); await flushAsync(); - messages = await loadMessages(instance); + messages = await model.loadMessages(); expect(messages[0]).toEqual(expect.objectContaining({ status: MessageStatus.Failure, })); @@ -630,16 +613,16 @@ describe('AI Assistant error handling', () => { describe('chat closed during processing', () => { it('should abort request and show failure after closing chat with confirm', async () => { - const { instance } = await createDataGridWithAiAndPopup(); - - sendAiRequest(instance, 'Sort by name'); + const { model } = await createDataGridWithAiAndPopup(); - expect(findMessageElements().length).toBe(1); - expect(getMessageStatusClass(findMessageElements().eq(0))).toBe(MessageStatus.Pending); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - const viewController = instance.getController('aiAssistantViewController'); + expect(model.findMessageElements().length).toBe(1); + expect(model.getMessageStatusClass(model.findMessageElements().eq(0))) + .toBe(MessageStatus.Pending); - await viewController.toggle().catch(() => {}); + await model.togglePopup().catch(() => {}); jest.runAllTimers(); await flushAsync(); @@ -651,14 +634,14 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - await viewController.toggle(); + await model.togglePopup(); jest.runAllTimers(); await flushAsync(); - const $messages = findMessageElements(); + const $messages = model.findMessageElements(); expect($messages.length).toBe(1); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) .toBe('Request stopped.'); }); @@ -666,15 +649,16 @@ describe('AI Assistant error handling', () => { describe('regeneration after failure', () => { it('should reset to pending and then succeed after regeneration', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'Sort by Name'); + model.sendAiRequest('Sort by Name'); + jest.runAllTimers(); getLastCallbacks().onError?.(new Error('Network error')); await flushAsync(); - let $messages = findMessageElements(); + let $messages = model.findMessageElements(); expect($messages.length).toBe(1); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); const regenerateButton = $messages.eq(0) .find(`.${CLASSES.messageRegenerateButton}`).get(0) as HTMLElement; @@ -684,9 +668,9 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - $messages = findMessageElements(); + $messages = model.findMessageElements(); expect($messages.length).toBe(1); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); getLastCallbacks().onComplete?.({ actions: [{ name: 'sorting', args: { column: 'Name' } }], @@ -694,17 +678,18 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - $messages = findMessageElements(); + $messages = model.findMessageElements(); expect($messages.length).toBe(1); - expect(getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); + expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); }); it('should reject regeneration while another request is processing', async () => { - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - sendAiRequest(instance, 'First request'); + model.sendAiRequest('First request'); + jest.runAllTimers(); - const controller = instance.getController('aiAssistant'); + const controller = model.getAiAssistantController(); const store = controller.getMessageStore(); const aiMessage: AIMessage = { @@ -731,7 +716,7 @@ describe('AI Assistant error handling', () => { it('should handle customizeResponseTitle returning empty string', async () => { const mockIntegration = createMockAIIntegration(); - const { instance } = await createDataGridWithAiAndPopup({ + const { model } = await createDataGridWithAiAndPopup({ aiAssistant: { enabled: true, aiIntegration: mockIntegration.aiIntegration, @@ -740,14 +725,15 @@ describe('AI Assistant error handling', () => { }, }); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); const actions = [{ name: 'sorting', args: { column: 'Name' } }]; mockIntegration.getLastCallbacks().onComplete?.({ actions }); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -760,13 +746,14 @@ describe('AI Assistant error handling', () => { describe('dispose during processing', () => { it('should abort request and mark message as failure on dispose', async () => { - const { instance } = await createDataGridWithAiAndPopup(); + const { model } = await createDataGridWithAiAndPopup(); - const controller = instance.getController('aiAssistant'); + const controller = model.getAiAssistantController(); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - const messagesBefore = await loadMessages(instance); + const messagesBefore = await model.loadMessages(); expect(messagesBefore[0]).toEqual(expect.objectContaining({ status: MessageStatus.Pending, })); @@ -774,7 +761,7 @@ describe('AI Assistant error handling', () => { controller.dispose(); await flushAsync(); - const messagesAfter = await loadMessages(instance); + const messagesAfter = await model.loadMessages(); expect(messagesAfter[0]).toEqual(expect.objectContaining({ status: MessageStatus.Failure, errorText: 'Request stopped.', @@ -784,30 +771,34 @@ describe('AI Assistant error handling', () => { describe('resilience after consecutive failures', () => { it('should process request successfully after multiple prior failures', async () => { - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup(); - const controller = instance.getController('aiAssistant'); + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup(); + const controller = model.getAiAssistantController(); - sendAiRequest(instance, 'Request 1'); + model.sendAiRequest('Request 1'); + jest.runAllTimers(); getLastCallbacks().onError?.(new Error('error 1')); await flushAsync(); - sendAiRequest(instance, 'Request 2'); + model.sendAiRequest('Request 2'); + jest.runAllTimers(); getLastCallbacks().onComplete?.({} as ExecuteGridAssistantCommandResult); await flushAsync(); await flushAsync(); - sendAiRequest(instance, 'Request 3'); + model.sendAiRequest('Request 3'); + jest.runAllTimers(); controller.abortRequest(); await flushAsync(); - sendAiRequest(instance, 'Request 4'); + model.sendAiRequest('Request 4'); + jest.runAllTimers(); getLastCallbacks().onComplete?.({ actions: [{ name: 'sorting', args: { column: 'Name' } }], }); await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toHaveLength(4); @@ -843,16 +834,17 @@ describe('AI Assistant error handling', () => { it('should handle error response identically with remote data', async () => { const remoteStore = createRemoteDataSource(LOCAL_DATA); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup({ + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup({ dataSource: remoteStore, }); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); getLastCallbacks().onError?.(new Error('Remote network error')); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -866,11 +858,12 @@ describe('AI Assistant error handling', () => { it('should handle success response identically with remote data', async () => { const remoteStore = createRemoteDataSource(LOCAL_DATA); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup({ + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup({ dataSource: remoteStore, }); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); getLastCallbacks().onComplete?.({ actions: [{ name: 'sorting', args: { column: 'Name' } }], @@ -878,7 +871,7 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - const messages = await loadMessages(instance); + const messages = await model.loadMessages(); expect(messages).toEqual([ expect.objectContaining({ @@ -890,13 +883,14 @@ describe('AI Assistant error handling', () => { it('should handle delayed remote data load with error response', async () => { const remoteStore = createRemoteDataSource(LOCAL_DATA, 100); - const { instance, getLastCallbacks } = await createDataGridWithAiAndPopup({ + const { model, getLastCallbacks } = await createDataGridWithAiAndPopup({ dataSource: remoteStore, }); - sendAiRequest(instance, 'Sort by name'); + model.sendAiRequest('Sort by name'); + jest.runAllTimers(); - let messages = await loadMessages(instance); + let messages = await model.loadMessages(); expect(messages[0]).toEqual(expect.objectContaining({ status: MessageStatus.Pending, })); @@ -905,7 +899,7 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - messages = await loadMessages(instance); + messages = await model.loadMessages(); expect(messages[0]).toEqual(expect.objectContaining({ status: MessageStatus.Failure, From 75c086d14e816d4b49e1817eb5e9cb42d0db83c0 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 00:07:35 +0400 Subject: [PATCH 04/11] simplify methods naming --- .../__tests__/__mock__/model/ai_assistant.ts | 18 ++++++--- .../error_handling.integration.test.ts | 40 +++++++++---------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts index f04f302246c8..c0c0a9e158c7 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -36,15 +36,21 @@ export class AIAssistantDataGridModel extends DataGridModel { return this.getMessageStore().load() as Promise; } - public findMessageElements(): dxElementWrapper { + public findMessages(): dxElementWrapper { return $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`); } - public getMessageStatusClass($message: dxElementWrapper): string { - if ($message.hasClass(CLASSES.messagePending)) return MessageStatus.Pending; - if ($message.hasClass(CLASSES.messageSuccess)) return MessageStatus.Success; - if ($message.hasClass(CLASSES.messageError)) return MessageStatus.Failure; - return ''; + public getMessageStatus($message: dxElementWrapper): MessageStatus { + if ($message.hasClass(CLASSES.messagePending)) { + return MessageStatus.Pending; + } + if ($message.hasClass(CLASSES.messageSuccess)) { + return MessageStatus.Success; + } + if ($message.hasClass(CLASSES.messageError)) { + return MessageStatus.Failure; + } + return '' as never; } public async togglePopup(): Promise { diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index 6e70ab57285a..6c0af9d24643 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -182,10 +182,10 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessageElements(); + const $messages = model.findMessages(); expect($messages.length).toBe(1); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) .toBe('Invalid response from the AI service. Please try again.'); }); @@ -360,10 +360,10 @@ describe('AI Assistant error handling', () => { model.getAiAssistantController().abortRequest(); await flushAsync(); - const $messages = model.findMessageElements(); + const $messages = model.findMessages(); expect($messages.length).toBe(1); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) .toBe('Request stopped.'); }); @@ -449,8 +449,8 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessageElements(); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + const $messages = model.findMessages(); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); }); it('should set failure status when commands contain aborted items', async () => { @@ -512,8 +512,8 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessageElements(); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); + const $messages = model.findMessages(); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Success); expect($messages.eq(0).find(`.${CLASSES.actionListItemText}`).text()) .toBe('Sorted by Name ascending'); }); @@ -563,8 +563,8 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessageElements(); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); + const $messages = model.findMessages(); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Pending); }); it('should transition from pending to success after delayed response', async () => { @@ -618,8 +618,8 @@ describe('AI Assistant error handling', () => { model.sendAiRequest('Sort by name'); jest.runAllTimers(); - expect(model.findMessageElements().length).toBe(1); - expect(model.getMessageStatusClass(model.findMessageElements().eq(0))) + expect(model.findMessages().length).toBe(1); + expect(model.getMessageStatus(model.findMessages().eq(0))) .toBe(MessageStatus.Pending); await model.togglePopup().catch(() => {}); @@ -638,10 +638,10 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - const $messages = model.findMessageElements(); + const $messages = model.findMessages(); expect($messages.length).toBe(1); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) .toBe('Request stopped.'); }); @@ -656,9 +656,9 @@ describe('AI Assistant error handling', () => { getLastCallbacks().onError?.(new Error('Network error')); await flushAsync(); - let $messages = model.findMessageElements(); + let $messages = model.findMessages(); expect($messages.length).toBe(1); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); const regenerateButton = $messages.eq(0) .find(`.${CLASSES.messageRegenerateButton}`).get(0) as HTMLElement; @@ -668,9 +668,9 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - $messages = model.findMessageElements(); + $messages = model.findMessages(); expect($messages.length).toBe(1); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Pending); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Pending); getLastCallbacks().onComplete?.({ actions: [{ name: 'sorting', args: { column: 'Name' } }], @@ -678,9 +678,9 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - $messages = model.findMessageElements(); + $messages = model.findMessages(); expect($messages.length).toBe(1); - expect(model.getMessageStatusClass($messages.eq(0))).toBe(MessageStatus.Success); + expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Success); }); it('should reject regeneration while another request is processing', async () => { From b39c2cb3fb01de8d86c9f84d1e31392d242a74ba Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 00:18:25 +0400 Subject: [PATCH 05/11] enrich POM with chat methods --- .../__tests__/__mock__/model/ai_assistant.ts | 19 +++++++++++++++++++ .../error_handling.integration.test.ts | 12 +++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts index c0c0a9e158c7..ca657ca429d8 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -53,6 +53,25 @@ export class AIAssistantDataGridModel extends DataGridModel { return '' as never; } + public getErrorMessage(messageIndex: number): dxElementWrapper { + return this.findMessages() + .eq(messageIndex) + .find(`.${CLASSES.messageErrorText}`); + } + + public getActionList(messageIndex: number): dxElementWrapper { + return this.findMessages() + .eq(messageIndex) + .find(`.${CLASSES.actionListItemText}`); + } + + public getRegenerateButton(messageIndex: number): HTMLElement { + return this.findMessages() + .eq(messageIndex) + .find(`.${CLASSES.messageRegenerateButton}`) + .get(0) as HTMLElement; + } + public async togglePopup(): Promise { await this.getAiAssistantViewController().toggle(); } diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index 6c0af9d24643..39e4e0dbf21d 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -26,7 +26,6 @@ import { import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; import type { AIMessage, CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; -import { CLASSES } from '@ts/grids/grid_core/ai_chat/const'; interface MockAIIntegrationResult { aiIntegration: AIIntegration; @@ -186,7 +185,7 @@ describe('AI Assistant error handling', () => { expect($messages.length).toBe(1); expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); - expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) + expect(model.getErrorMessage(0).text()) .toBe('Invalid response from the AI service. Please try again.'); }); }); @@ -364,7 +363,7 @@ describe('AI Assistant error handling', () => { expect($messages.length).toBe(1); expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); - expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) + expect(model.getErrorMessage(0).text()) .toBe('Request stopped.'); }); }); @@ -514,7 +513,7 @@ describe('AI Assistant error handling', () => { const $messages = model.findMessages(); expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Success); - expect($messages.eq(0).find(`.${CLASSES.actionListItemText}`).text()) + expect(model.getActionList(0).text()) .toBe('Sorted by Name ascending'); }); @@ -642,7 +641,7 @@ describe('AI Assistant error handling', () => { expect($messages.length).toBe(1); expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); - expect($messages.eq(0).find(`.${CLASSES.messageErrorText}`).text()) + expect(model.getErrorMessage(0).text()) .toBe('Request stopped.'); }); }); @@ -660,8 +659,7 @@ describe('AI Assistant error handling', () => { expect($messages.length).toBe(1); expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); - const regenerateButton = $messages.eq(0) - .find(`.${CLASSES.messageRegenerateButton}`).get(0) as HTMLElement; + const regenerateButton = model.getRegenerateButton(0); expect(regenerateButton).toBeTruthy(); regenerateButton.click(); From 60a9a8dcff22d626234b44e7037fc1c92a965979 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 00:27:01 +0400 Subject: [PATCH 06/11] enrich POM with chat methods --- .../__tests__/__mock__/model/ai_assistant.ts | 19 ++++--- .../error_handling.integration.test.ts | 49 +++++++------------ 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts index ca657ca429d8..452ff96c624b 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -36,11 +36,17 @@ export class AIAssistantDataGridModel extends DataGridModel { return this.getMessageStore().load() as Promise; } - public findMessages(): dxElementWrapper { + public getMessages(): dxElementWrapper { return $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`); } - public getMessageStatus($message: dxElementWrapper): MessageStatus { + public getMessage(messageIndex: number): dxElementWrapper { + return this.getMessages().eq(messageIndex); + } + + public getMessageStatus(messageIndex: number): MessageStatus { + const $message = this.getMessage(messageIndex); + if ($message.hasClass(CLASSES.messagePending)) { return MessageStatus.Pending; } @@ -54,20 +60,17 @@ export class AIAssistantDataGridModel extends DataGridModel { } public getErrorMessage(messageIndex: number): dxElementWrapper { - return this.findMessages() - .eq(messageIndex) + return this.getMessage(messageIndex) .find(`.${CLASSES.messageErrorText}`); } public getActionList(messageIndex: number): dxElementWrapper { - return this.findMessages() - .eq(messageIndex) + return this.getMessage(messageIndex) .find(`.${CLASSES.actionListItemText}`); } public getRegenerateButton(messageIndex: number): HTMLElement { - return this.findMessages() - .eq(messageIndex) + return this.getMessage(messageIndex) .find(`.${CLASSES.messageRegenerateButton}`) .get(0) as HTMLElement; } diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index 39e4e0dbf21d..cceb5e025c66 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -102,8 +102,9 @@ const createDataGridWithAiAndPopup = async ( getAbortSpy: () => jest.Mock; }> => { const result = await createDataGridWithAi(overrides); + const { model } = result; - await result.model.togglePopup(); + await model.togglePopup(); jest.runAllTimers(); return result; @@ -181,10 +182,8 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessages(); - - expect($messages.length).toBe(1); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); expect(model.getErrorMessage(0).text()) .toBe('Invalid response from the AI service. Please try again.'); }); @@ -359,10 +358,8 @@ describe('AI Assistant error handling', () => { model.getAiAssistantController().abortRequest(); await flushAsync(); - const $messages = model.findMessages(); - - expect($messages.length).toBe(1); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); expect(model.getErrorMessage(0).text()) .toBe('Request stopped.'); }); @@ -448,8 +445,7 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessages(); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); }); it('should set failure status when commands contain aborted items', async () => { @@ -511,8 +507,7 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessages(); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Success); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Success); expect(model.getActionList(0).text()) .toBe('Sorted by Name ascending'); }); @@ -562,8 +557,7 @@ describe('AI Assistant error handling', () => { }), ]); - const $messages = model.findMessages(); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Pending); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Pending); }); it('should transition from pending to success after delayed response', async () => { @@ -617,8 +611,8 @@ describe('AI Assistant error handling', () => { model.sendAiRequest('Sort by name'); jest.runAllTimers(); - expect(model.findMessages().length).toBe(1); - expect(model.getMessageStatus(model.findMessages().eq(0))) + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)) .toBe(MessageStatus.Pending); await model.togglePopup().catch(() => {}); @@ -637,10 +631,8 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - const $messages = model.findMessages(); - - expect($messages.length).toBe(1); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); expect(model.getErrorMessage(0).text()) .toBe('Request stopped.'); }); @@ -655,9 +647,8 @@ describe('AI Assistant error handling', () => { getLastCallbacks().onError?.(new Error('Network error')); await flushAsync(); - let $messages = model.findMessages(); - expect($messages.length).toBe(1); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Failure); + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); const regenerateButton = model.getRegenerateButton(0); expect(regenerateButton).toBeTruthy(); @@ -666,9 +657,8 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - $messages = model.findMessages(); - expect($messages.length).toBe(1); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Pending); + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Pending); getLastCallbacks().onComplete?.({ actions: [{ name: 'sorting', args: { column: 'Name' } }], @@ -676,9 +666,8 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - $messages = model.findMessages(); - expect($messages.length).toBe(1); - expect(model.getMessageStatus($messages.eq(0))).toBe(MessageStatus.Success); + expect(model.getMessages().length).toBe(1); + expect(model.getMessageStatus(0)).toBe(MessageStatus.Success); }); it('should reject regeneration while another request is processing', async () => { From 2824ce6452b4989c58e59c2adc40c389e31ed71b Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 00:55:48 +0400 Subject: [PATCH 07/11] enrich POM with methods --- .../__tests__/__mock__/model/ai_assistant.ts | 21 ++++++++++++++----- .../error_handling.integration.test.ts | 14 ++++--------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts index 452ff96c624b..f2901004a497 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -5,6 +5,7 @@ import type { Message } from '@js/ui/chat'; import type { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; import type { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view_controller'; import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; +import type { AIMessage } from '@ts/grids/grid_core/ai_assistant/types'; import { CLASSES } from '@ts/grids/grid_core/ai_chat/const'; import { DataGridModel } from './data_grid'; @@ -21,11 +22,21 @@ export class AIAssistantDataGridModel extends DataGridModel { public sendAiRequest(text: string): void { const controller = this.getAiAssistantController(); - controller.sendRequestToAI({ - author: { id: 'user', name: 'User' }, - text, - timestamp: new Date().toISOString(), - } as Message).catch(() => {}); + controller + .sendRequestToAI({ + author: { id: 'user', name: 'User' }, + text, + timestamp: new Date().toISOString(), + } as Message) + .catch(() => {}); + } + + public sendAiRequestWithResponse(message: Message | AIMessage): Promise { + const controller = this.getAiAssistantController(); + + return controller + .sendRequestToAI(message) + .catch(() => {}); } public getMessageStore(): ArrayStore { diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index cceb5e025c66..665a6110fd5e 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -372,16 +372,13 @@ describe('AI Assistant error handling', () => { model.sendAiRequest('First request'); jest.runAllTimers(); - const controller = model.getAiAssistantController(); - - const secondPromise = controller.sendRequestToAI({ + const message = { author: { id: 'user', name: 'User' }, text: 'Second request', timestamp: new Date().toISOString(), - } as Message); - secondPromise.catch(() => {}); + } as Message; - await expect(secondPromise) + await expect(model.sendAiRequestWithResponse(message)) .rejects.toThrow('Request already in progress. Please wait.'); const messages = await model.loadMessages(); @@ -691,10 +688,7 @@ describe('AI Assistant error handling', () => { await store.insert(aiMessage); - const regeneratePromise = controller.sendRequestToAI(aiMessage); - regeneratePromise.catch(() => {}); - - await expect(regeneratePromise) + await expect(model.sendAiRequestWithResponse(aiMessage)) .rejects.toThrow('Request already in progress. Please wait.'); }); }); From 80999f71f9ee5c9254aca91648ad211ac929246e Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 01:21:02 +0400 Subject: [PATCH 08/11] add AIChatModel --- .../__tests__/__mock__/model/ai_assistant.ts | 44 ++------------- .../__tests__/__mock__/model/ai_chat.ts | 50 +++++++++++++++++ .../error_handling.integration.test.ts | 54 +++++++++++-------- 3 files changed, 85 insertions(+), 63 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_chat.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts index f2901004a497..06e095d4d0ce 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -1,12 +1,9 @@ import type { ArrayStore } from '@js/common/data'; -import type { dxElementWrapper } from '@js/core/renderer'; -import $ from '@js/core/renderer'; import type { Message } from '@js/ui/chat'; +import { AIChatModel } from '@ts/grids/data_grid/__tests__/__mock__/model/ai_chat'; import type { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; import type { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view_controller'; -import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; import type { AIMessage } from '@ts/grids/grid_core/ai_assistant/types'; -import { CLASSES } from '@ts/grids/grid_core/ai_chat/const'; import { DataGridModel } from './data_grid'; @@ -47,43 +44,8 @@ export class AIAssistantDataGridModel extends DataGridModel { return this.getMessageStore().load() as Promise; } - public getMessages(): dxElementWrapper { - return $(`.${CLASSES.aiChat}`).find(`.${CLASSES.message}`); - } - - public getMessage(messageIndex: number): dxElementWrapper { - return this.getMessages().eq(messageIndex); - } - - public getMessageStatus(messageIndex: number): MessageStatus { - const $message = this.getMessage(messageIndex); - - if ($message.hasClass(CLASSES.messagePending)) { - return MessageStatus.Pending; - } - if ($message.hasClass(CLASSES.messageSuccess)) { - return MessageStatus.Success; - } - if ($message.hasClass(CLASSES.messageError)) { - return MessageStatus.Failure; - } - return '' as never; - } - - public getErrorMessage(messageIndex: number): dxElementWrapper { - return this.getMessage(messageIndex) - .find(`.${CLASSES.messageErrorText}`); - } - - public getActionList(messageIndex: number): dxElementWrapper { - return this.getMessage(messageIndex) - .find(`.${CLASSES.actionListItemText}`); - } - - public getRegenerateButton(messageIndex: number): HTMLElement { - return this.getMessage(messageIndex) - .find(`.${CLASSES.messageRegenerateButton}`) - .get(0) as HTMLElement; + public getAiChatModel(): AIChatModel { + return new AIChatModel(); } public async togglePopup(): Promise { diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_chat.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_chat.ts new file mode 100644 index 000000000000..a240aef7b192 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_chat.ts @@ -0,0 +1,50 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; +import { CLASSES } from '@ts/grids/grid_core/ai_chat/const'; + +export class AIChatModel { + protected readonly root: dxElementWrapper; + + constructor() { + this.root = $(`.${CLASSES.aiChat}`); + } + + public getMessages(): dxElementWrapper { + return this.root.find(`.${CLASSES.message}`); + } + + public getMessage(messageIndex: number): dxElementWrapper { + return this.getMessages().eq(messageIndex); + } + + public getMessageStatus(messageIndex: number): MessageStatus { + const $message = this.getMessage(messageIndex); + if ($message.hasClass(CLASSES.messagePending)) { + return MessageStatus.Pending; + } + if ($message.hasClass(CLASSES.messageSuccess)) { + return MessageStatus.Success; + } + if ($message.hasClass(CLASSES.messageError)) { + return MessageStatus.Failure; + } + return '' as never; + } + + public getErrorMessage(messageIndex: number): dxElementWrapper { + return this.getMessage(messageIndex) + .find(`.${CLASSES.messageErrorText}`); + } + + public getActionList(messageIndex: number): dxElementWrapper { + return this.getMessage(messageIndex) + .find(`.${CLASSES.actionListItemText}`); + } + + public getRegenerateButton(messageIndex: number): HTMLElement { + return this.getMessage(messageIndex) + .find(`.${CLASSES.messageRegenerateButton}`) + .get(0) as HTMLElement; + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index 665a6110fd5e..971a6e72e387 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -182,9 +182,10 @@ describe('AI Assistant error handling', () => { }), ]); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); - expect(model.getErrorMessage(0).text()) + const aiChat = model.getAiChatModel(); + expect(aiChat.getMessages().length).toBe(1); + expect(aiChat.getMessageStatus(0)).toBe(MessageStatus.Failure); + expect(aiChat.getErrorMessage(0).text()) .toBe('Invalid response from the AI service. Please try again.'); }); }); @@ -358,9 +359,10 @@ describe('AI Assistant error handling', () => { model.getAiAssistantController().abortRequest(); await flushAsync(); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); - expect(model.getErrorMessage(0).text()) + const aiChat = model.getAiChatModel(); + expect(aiChat.getMessages().length).toBe(1); + expect(aiChat.getMessageStatus(0)).toBe(MessageStatus.Failure); + expect(aiChat.getErrorMessage(0).text()) .toBe('Request stopped.'); }); }); @@ -442,7 +444,8 @@ describe('AI Assistant error handling', () => { }), ]); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); + expect(model.getAiChatModel().getMessageStatus(0)) + .toBe(MessageStatus.Failure); }); it('should set failure status when commands contain aborted items', async () => { @@ -504,8 +507,9 @@ describe('AI Assistant error handling', () => { }), ]); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Success); - expect(model.getActionList(0).text()) + const aiChat = model.getAiChatModel(); + expect(aiChat.getMessageStatus(0)).toBe(MessageStatus.Success); + expect(aiChat.getActionList(0).text()) .toBe('Sorted by Name ascending'); }); @@ -554,7 +558,8 @@ describe('AI Assistant error handling', () => { }), ]); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Pending); + expect(model.getAiChatModel().getMessageStatus(0)) + .toBe(MessageStatus.Pending); }); it('should transition from pending to success after delayed response', async () => { @@ -608,8 +613,9 @@ describe('AI Assistant error handling', () => { model.sendAiRequest('Sort by name'); jest.runAllTimers(); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)) + const aiChat = model.getAiChatModel(); + expect(aiChat.getMessages().length).toBe(1); + expect(aiChat.getMessageStatus(0)) .toBe(MessageStatus.Pending); await model.togglePopup().catch(() => {}); @@ -628,9 +634,10 @@ describe('AI Assistant error handling', () => { jest.runAllTimers(); await flushAsync(); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); - expect(model.getErrorMessage(0).text()) + const aiChatAfter = model.getAiChatModel(); + expect(aiChatAfter.getMessages().length).toBe(1); + expect(aiChatAfter.getMessageStatus(0)).toBe(MessageStatus.Failure); + expect(aiChatAfter.getErrorMessage(0).text()) .toBe('Request stopped.'); }); }); @@ -644,18 +651,20 @@ describe('AI Assistant error handling', () => { getLastCallbacks().onError?.(new Error('Network error')); await flushAsync(); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Failure); + expect(model.getAiChatModel().getMessages().length).toBe(1); + expect(model.getAiChatModel().getMessageStatus(0)) + .toBe(MessageStatus.Failure); - const regenerateButton = model.getRegenerateButton(0); + const regenerateButton = model.getAiChatModel().getRegenerateButton(0); expect(regenerateButton).toBeTruthy(); regenerateButton.click(); jest.runAllTimers(); await flushAsync(); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Pending); + expect(model.getAiChatModel().getMessages().length).toBe(1); + expect(model.getAiChatModel().getMessageStatus(0)) + .toBe(MessageStatus.Pending); getLastCallbacks().onComplete?.({ actions: [{ name: 'sorting', args: { column: 'Name' } }], @@ -663,8 +672,9 @@ describe('AI Assistant error handling', () => { await flushAsync(); await flushAsync(); - expect(model.getMessages().length).toBe(1); - expect(model.getMessageStatus(0)).toBe(MessageStatus.Success); + expect(model.getAiChatModel().getMessages().length).toBe(1); + expect(model.getAiChatModel().getMessageStatus(0)) + .toBe(MessageStatus.Success); }); it('should reject regeneration while another request is processing', async () => { From f90daeb62421208f00a183c3cc22af2dee7343a4 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 01:31:22 +0400 Subject: [PATCH 09/11] fix and rename a model method --- .../data_grid/__tests__/__mock__/model/ai_assistant.ts | 8 ++------ .../__tests__/error_handling.integration.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts index 06e095d4d0ce..ff135694b9dc 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts @@ -28,12 +28,8 @@ export class AIAssistantDataGridModel extends DataGridModel { .catch(() => {}); } - public sendAiRequestWithResponse(message: Message | AIMessage): Promise { - const controller = this.getAiAssistantController(); - - return controller - .sendRequestToAI(message) - .catch(() => {}); + public sendAiRequestRaw(message: Message | AIMessage): Promise { + return this.getAiAssistantController().sendRequestToAI(message); } public getMessageStore(): ArrayStore { diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index 971a6e72e387..3dcb791c9754 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -380,7 +380,7 @@ describe('AI Assistant error handling', () => { timestamp: new Date().toISOString(), } as Message; - await expect(model.sendAiRequestWithResponse(message)) + await expect(model.sendAiRequestRaw(message)) .rejects.toThrow('Request already in progress. Please wait.'); const messages = await model.loadMessages(); @@ -698,7 +698,7 @@ describe('AI Assistant error handling', () => { await store.insert(aiMessage); - await expect(model.sendAiRequestWithResponse(aiMessage)) + await expect(model.sendAiRequestRaw(aiMessage)) .rejects.toThrow('Request already in progress. Please wait.'); }); }); From ea24abb091e0a930dc7fed72fbf0a264575aa45f Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 13:59:43 +0400 Subject: [PATCH 10/11] use model method instead of controller one --- .../__tests__/error_handling.integration.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts index 3dcb791c9754..2cd770c8cf6d 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts @@ -683,8 +683,7 @@ describe('AI Assistant error handling', () => { model.sendAiRequest('First request'); jest.runAllTimers(); - const controller = model.getAiAssistantController(); - const store = controller.getMessageStore(); + const messageStore = model.getMessageStore(); const aiMessage: AIMessage = { id: 'assistant-old', @@ -696,7 +695,7 @@ describe('AI Assistant error handling', () => { errorText: 'Network error', }; - await store.insert(aiMessage); + await messageStore.insert(aiMessage); await expect(model.sendAiRequestRaw(aiMessage)) .rejects.toThrow('Request already in progress. Please wait.'); From 18dbc631bf28e10342f65f3cb9aa905b0c216647 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Fri, 22 May 2026 15:52:21 +0400 Subject: [PATCH 11/11] move tests and models to grid_core --- .../data_grid/__tests__/__mock__/model/data_grid.ts | 6 +++--- .../__internal/grids/data_grid/__tests__/types.ts | 6 ------ .../__tests__/__mock__/model/ai_assistant.ts | 13 ++++++++++--- .../__tests__/__mock__/model/ai_chat.ts | 0 .../__tests__/error_handling.integration.test.ts | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) delete mode 100644 packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts rename packages/devextreme/js/__internal/grids/{data_grid => grid_core}/__tests__/__mock__/model/ai_assistant.ts (74%) rename packages/devextreme/js/__internal/grids/{data_grid => grid_core}/__tests__/__mock__/model/ai_chat.ts (100%) rename packages/devextreme/js/__internal/grids/{data_grid => grid_core}/ai_assistant/__tests__/error_handling.integration.test.ts (99%) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts index 54477f2f44a1..430b8f0c7c62 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts @@ -1,13 +1,13 @@ import type { Column } from '@js/ui/data_grid'; import DataGrid from '@js/ui/data_grid'; -import type { DataGridWithControllers } from '@ts/grids/data_grid/__tests__/types'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; import { DataGridBaseModel } from '@ts/grids/grid_core/__tests__/__mock__/model/data_grid_base'; export class DataGridModel extends DataGridBaseModel { protected NAME = 'dxDataGrid'; - public getInstance(): DataGridWithControllers { - return DataGrid.getInstance(this.root) as DataGridWithControllers; + public getInstance(): DataGridInstance { + return DataGrid.getInstance(this.root) as DataGridInstance; } public apiGetVisibleColumns(headerLevel?: number): Column[] { diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts deleted file mode 100644 index b2514cfd673b..000000000000 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type DataGrid from '@js/ui/data_grid'; -import type { Controllers } from '@ts/grids/grid_core/m_types'; - -export interface DataGridWithControllers extends DataGrid { - getController: (name: T) => Controllers[T]; -} diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/ai_assistant.ts similarity index 74% rename from packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts rename to packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/ai_assistant.ts index ff135694b9dc..f36f7fa90089 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_assistant.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/ai_assistant.ts @@ -1,13 +1,20 @@ import type { ArrayStore } from '@js/common/data'; import type { Message } from '@js/ui/chat'; -import { AIChatModel } from '@ts/grids/data_grid/__tests__/__mock__/model/ai_chat'; +import DataGrid from '@js/ui/data_grid'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { AIChatModel } from '@ts/grids/grid_core/__tests__/__mock__/model/ai_chat'; +import { DataGridBaseModel } from '@ts/grids/grid_core/__tests__/__mock__/model/data_grid_base'; import type { AIAssistantController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_controller'; import type { AIAssistantViewController } from '@ts/grids/grid_core/ai_assistant/ai_assistant_view_controller'; import type { AIMessage } from '@ts/grids/grid_core/ai_assistant/types'; -import { DataGridModel } from './data_grid'; +export class AIAssistantDataGridModel extends DataGridBaseModel { + protected NAME = 'dxDataGrid'; + + public getInstance(): DataGridInstance { + return DataGrid.getInstance(this.root) as DataGridInstance; + } -export class AIAssistantDataGridModel extends DataGridModel { public getAiAssistantController(): AIAssistantController { return this.getInstance().getController('aiAssistant'); } diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_chat.ts b/packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/ai_chat.ts similarity index 100% rename from packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/ai_chat.ts rename to packages/devextreme/js/__internal/grids/grid_core/__tests__/__mock__/model/ai_chat.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts similarity index 99% rename from packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts rename to packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts index 2cd770c8cf6d..2d32bb6c8ba7 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/ai_assistant/__tests__/error_handling.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/__tests__/error_handling.integration.test.ts @@ -16,13 +16,13 @@ import type { Message } from '@js/ui/chat'; import errors from '@js/ui/widget/ui.errors'; import { AIIntegration } from '@ts/core/ai_integration/core/ai_integration'; import CustomStore from '@ts/data/m_custom_store'; -import { AIAssistantDataGridModel } from '@ts/grids/data_grid/__tests__/__mock__/model/ai_assistant'; import { afterTest, beforeTest, createDataGrid, flushAsync, } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { AIAssistantDataGridModel } from '@ts/grids/grid_core/__tests__/__mock__/model/ai_assistant'; import { MessageStatus } from '@ts/grids/grid_core/ai_assistant/const'; import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands'; import type { AIMessage, CommandResult } from '@ts/grids/grid_core/ai_assistant/types';