Skip to content

Commit 609e118

Browse files
authored
AI Assistant: Integration leftovers (#33585)
1 parent 2d07d7a commit 609e118

8 files changed

Lines changed: 1020 additions & 48 deletions

File tree

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import {
2+
beforeEach,
3+
describe,
4+
expect,
5+
it,
6+
jest,
7+
} from '@jest/globals';
8+
import type { ExecuteGridAssistantCommandResult } from '@js/common/ai-integration';
9+
import type { ArrayStore } from '@js/common/data';
10+
import type { Message } from '@js/ui/chat';
11+
import { coreCommands } from '@ts/grids/grid_core/ai_assistant/commands';
12+
import {
13+
AI_ASSISTANT_AUTHOR,
14+
AI_ASSISTANT_AUTHOR_ID,
15+
MessageStatus,
16+
} from '@ts/grids/grid_core/ai_assistant/const';
17+
import { GridCommands } from '@ts/grids/grid_core/ai_assistant/grid_commands';
18+
import type {
19+
AIAssistantRequestCallbacks,
20+
AIMessage,
21+
CommandResult,
22+
} from '@ts/grids/grid_core/ai_assistant/types';
23+
import type { InternalGrid } from '@ts/grids/grid_core/m_types';
24+
25+
import { DataGridAIAssistantController } from '../ai_assistant_controller';
26+
import { DataGridAIAssistantIntegrationController } from '../ai_assistant_integration_controller';
27+
import { dataGridCommands } from '../commands/index';
28+
29+
jest.mock('@ts/grids/grid_core/ai_assistant/grid_commands');
30+
jest.mock('../ai_assistant_integration_controller');
31+
32+
const MockedGridCommands = GridCommands as jest.MockedClass<typeof GridCommands>;
33+
const MockedDataGridAIAssistantIntegrationController = DataGridAIAssistantIntegrationController as
34+
jest.MockedClass<typeof DataGridAIAssistantIntegrationController>;
35+
36+
let sendRequestCallbacks: AIAssistantRequestCallbacks<ExecuteGridAssistantCommandResult> = {};
37+
38+
const createController = (
39+
options: Record<string, unknown> = {},
40+
): DataGridAIAssistantController => {
41+
const mockComponent = {
42+
_optionCache: {},
43+
_controllers: {},
44+
option: jest.fn((name?: string) => {
45+
if (name === undefined) {
46+
return options;
47+
}
48+
49+
return options[name];
50+
}),
51+
_createActionByOption: jest.fn(() => jest.fn()),
52+
};
53+
54+
const controller = new DataGridAIAssistantController(
55+
mockComponent as unknown as InternalGrid,
56+
);
57+
controller.init();
58+
59+
return controller;
60+
};
61+
62+
const getStore = (
63+
controller: DataGridAIAssistantController,
64+
): ArrayStore<Message, string> => {
65+
const dataSource = controller.getMessageDataSource() as {
66+
store: ArrayStore<Message, string>;
67+
};
68+
return dataSource.store;
69+
};
70+
71+
describe('DataGridAIAssistantController', () => {
72+
beforeEach(() => {
73+
jest.clearAllMocks();
74+
75+
(MockedGridCommands.mockImplementation as jest.Mock).call(
76+
MockedGridCommands,
77+
() => ({
78+
validate: jest.fn().mockReturnValue(true),
79+
executeCommands: jest.fn<() => Promise<CommandResult[]>>()
80+
.mockResolvedValue([{ status: 'success', message: 'sort' }]),
81+
abort: jest.fn(),
82+
buildResponseSchema: jest.fn().mockReturnValue({ type: 'object' }),
83+
isExecuting: jest.fn().mockReturnValue(false),
84+
}),
85+
);
86+
87+
(MockedDataGridAIAssistantIntegrationController
88+
.mockImplementation as jest.Mock).call(
89+
MockedDataGridAIAssistantIntegrationController,
90+
() => ({
91+
init: jest.fn(),
92+
dispose: jest.fn(),
93+
sendRequest: jest.fn((
94+
_text: string,
95+
_responseSchema: unknown,
96+
callbacks?: AIAssistantRequestCallbacks<ExecuteGridAssistantCommandResult>,
97+
) => {
98+
sendRequestCallbacks = callbacks ?? {};
99+
}),
100+
abortRequest: jest.fn(() => {
101+
sendRequestCallbacks.onAbort?.();
102+
}),
103+
isRequestAwaitingCompletion: jest.fn().mockReturnValue(false),
104+
}),
105+
);
106+
});
107+
108+
describe('getGridCommandList', () => {
109+
it('should include all core commands', () => {
110+
createController();
111+
112+
const constructorCall = MockedGridCommands.mock.calls[0];
113+
const commandList = constructorCall[1];
114+
const commandNames = commandList.map((c) => c.name);
115+
116+
for (const coreCommand of coreCommands) {
117+
expect(commandNames).toContain(coreCommand.name);
118+
}
119+
});
120+
121+
it('should include all data grid specific commands', () => {
122+
createController();
123+
124+
const constructorCall = MockedGridCommands.mock.calls[0];
125+
const commandList = constructorCall[1];
126+
const commandNames = commandList.map((c) => c.name);
127+
128+
expect(commandNames).toContain('grouping');
129+
expect(commandNames).toContain('clearGrouping');
130+
expect(commandNames).toContain('summary');
131+
expect(commandNames).toContain('clearSummary');
132+
});
133+
134+
it('should extend core commands with data grid commands', () => {
135+
createController();
136+
137+
const constructorCall = MockedGridCommands.mock.calls[0];
138+
const commandList = constructorCall[1];
139+
140+
expect(commandList).toHaveLength(
141+
coreCommands.length + dataGridCommands.length,
142+
);
143+
});
144+
145+
it('should place core commands before data grid commands', () => {
146+
createController();
147+
148+
const constructorCall = MockedGridCommands.mock.calls[0];
149+
const commandList = constructorCall[1];
150+
const commandNames = commandList.map((c) => c.name);
151+
152+
const firstDataGridCommandIndex = commandNames.indexOf('grouping');
153+
const lastCoreCommandIndex = commandNames.indexOf(
154+
coreCommands[coreCommands.length - 1].name,
155+
);
156+
157+
expect(firstDataGridCommandIndex).toBeGreaterThan(lastCoreCommandIndex);
158+
});
159+
});
160+
161+
describe('getAiAssistantIntegrationController', () => {
162+
it('should create DataGridAIAssistantIntegrationController', () => {
163+
createController();
164+
165+
expect(MockedDataGridAIAssistantIntegrationController).toHaveBeenCalledTimes(1);
166+
});
167+
});
168+
169+
describe('inherited behavior', () => {
170+
it('should return dataSource with store', () => {
171+
const controller = createController();
172+
const dataSource = controller.getMessageDataSource() as {
173+
store: ArrayStore<Message, string>;
174+
};
175+
176+
expect(dataSource.store).toBeDefined();
177+
});
178+
179+
it('should create pending message in store', async () => {
180+
const controller = createController();
181+
182+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
183+
controller.sendRequestToAI({
184+
author: { id: 'user', name: 'User' },
185+
text: 'Group by category',
186+
timestamp: '2026-04-16T10:00:00.000Z',
187+
} as Message);
188+
189+
const messages = await getStore(controller).load();
190+
191+
expect(messages).toEqual([
192+
expect.objectContaining({
193+
id: expect.stringContaining(AI_ASSISTANT_AUTHOR_ID),
194+
author: AI_ASSISTANT_AUTHOR,
195+
status: MessageStatus.Pending,
196+
}),
197+
]);
198+
});
199+
200+
it('should complete message as success when command succeed', async () => {
201+
const controller = createController();
202+
203+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
204+
controller.sendRequestToAI({
205+
author: { id: 'user', name: 'User' },
206+
text: 'Group by category',
207+
timestamp: '2026-04-16T10:00:00.000Z',
208+
} as Message);
209+
210+
const actions = [{ name: 'grouping', args: { dataField: 'Category', groupIndex: 0 } }];
211+
sendRequestCallbacks.onComplete?.({ actions });
212+
await Promise.resolve();
213+
214+
const messages = await getStore(controller).load();
215+
216+
expect(messages).toEqual([
217+
expect.objectContaining({
218+
status: MessageStatus.Success,
219+
commands: [{ status: 'success', message: 'sort' }],
220+
}),
221+
]);
222+
});
223+
224+
it('should fail message when onError callback is called', async () => {
225+
const controller = createController();
226+
227+
const promise = controller.sendRequestToAI({
228+
author: { id: 'user', name: 'User' },
229+
text: 'Group by category',
230+
timestamp: '2026-04-16T10:00:00.000Z',
231+
} as Message);
232+
promise.catch(() => {});
233+
234+
sendRequestCallbacks.onError?.(new Error('Network error'));
235+
236+
const messages = await getStore(controller).load();
237+
238+
expect(messages).toEqual([
239+
expect.objectContaining({
240+
status: MessageStatus.Failure,
241+
errorText: 'Network error',
242+
}),
243+
]);
244+
245+
await expect(promise).rejects.toThrow('Network error');
246+
});
247+
248+
it('should abort request and fail message', async () => {
249+
const controller = createController();
250+
251+
const promise = controller.sendRequestToAI({
252+
author: { id: 'user', name: 'User' },
253+
text: 'Group by category',
254+
timestamp: '2026-04-16T10:00:00.000Z',
255+
} as Message);
256+
promise.catch(() => {});
257+
258+
controller.abortRequest();
259+
260+
const messages = await getStore(controller).load();
261+
262+
expect(messages).toEqual([
263+
expect.objectContaining({
264+
status: MessageStatus.Failure,
265+
}),
266+
]);
267+
268+
await expect(promise).rejects.toThrow();
269+
});
270+
271+
it('should call integration controller dispose on dispose', () => {
272+
const controller = createController();
273+
274+
const integrationInstance = MockedDataGridAIAssistantIntegrationController
275+
.mock.results[0].value as { dispose: jest.Mock };
276+
277+
controller.dispose();
278+
279+
expect(integrationInstance.dispose).toHaveBeenCalledTimes(1);
280+
});
281+
282+
it('should reject second request while first is processing', async () => {
283+
const controller = createController();
284+
285+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
286+
controller.sendRequestToAI({
287+
author: { id: 'user', name: 'User' },
288+
text: 'First request',
289+
timestamp: '2026-04-16T10:00:00.000Z',
290+
} as Message);
291+
292+
const secondPromise = controller.sendRequestToAI({
293+
author: { id: 'user', name: 'User' },
294+
text: 'Second request',
295+
timestamp: '2026-04-16T10:00:01.000Z',
296+
} as Message);
297+
secondPromise.catch(() => {});
298+
299+
await expect(secondPromise).rejects.toBeUndefined();
300+
});
301+
302+
it('should support regenerating failed AIMessage', async () => {
303+
const controller = createController();
304+
305+
const aiMessage: AIMessage = {
306+
id: 'assistant-123',
307+
author: AI_ASSISTANT_AUTHOR,
308+
text: MessageStatus.Failure,
309+
prompt: 'Group by category',
310+
status: MessageStatus.Failure,
311+
headerText: 'Failed to process request',
312+
errorText: 'Network error',
313+
};
314+
315+
const store = getStore(controller);
316+
await store.insert(aiMessage);
317+
318+
const promise = controller.sendRequestToAI(aiMessage);
319+
320+
const actions = [{ name: 'grouping', args: { dataField: 'Category', groupIndex: 0 } }];
321+
sendRequestCallbacks.onComplete?.({ actions });
322+
323+
await promise;
324+
325+
const messages = await store.load();
326+
327+
expect(messages).toEqual([
328+
expect.objectContaining({
329+
id: 'assistant-123',
330+
status: MessageStatus.Success,
331+
}),
332+
]);
333+
});
334+
});
335+
});

0 commit comments

Comments
 (0)