@eeacms/volto-eea-chatbot 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -752
- package/package.json +1 -1
- package/razzle.extend.js +8 -4
- package/src/ChatBlock/ChatBlockView.jsx +26 -2
- package/src/ChatBlock/chat/AIMessage.tsx +5 -1
- package/src/ChatBlock/chat/ChatWindow.tsx +12 -3
- package/src/ChatBlock/components/AutoResizeTextarea.jsx +2 -1
- package/src/ChatBlock/components/QualityCheckToggle.jsx +1 -1
- package/src/ChatBlock/hooks/useChatController.ts +10 -2
- package/src/ChatBlock/index.js +1 -1
- package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +8 -0
- package/src/ChatBlock/services/streamingService.ts +30 -26
- package/src/ChatBlock/style.less +3 -1
- package/src/ChatBlock/tests/ChatMessage.test.jsx +75 -0
- package/src/ChatBlock/tests/ClaimModal.test.jsx +136 -0
- package/src/ChatBlock/tests/ClaimSegments.test.jsx +206 -0
- package/src/ChatBlock/tests/CustomToolRenderer.test.jsx +241 -0
- package/src/ChatBlock/tests/FetchToolRenderer.test.jsx +161 -0
- package/src/ChatBlock/tests/ImageToolRenderer.test.jsx +178 -0
- package/src/ChatBlock/tests/MessageTextRenderer.test.jsx +227 -0
- package/src/ChatBlock/tests/MultiToolRenderer.test.jsx +134 -0
- package/src/ChatBlock/tests/ReasoningRenderer.test.jsx +163 -0
- package/src/ChatBlock/tests/RenderClaimView.test.jsx +191 -0
- package/src/ChatBlock/tests/RendererComponent.test.jsx +139 -0
- package/src/ChatBlock/tests/SearchToolRenderer.test.jsx +295 -0
- package/src/ChatBlock/tests/SourceChip.test.jsx +108 -0
- package/src/ChatBlock/tests/UserActionsToolbar.test.jsx +135 -0
- package/src/ChatBlock/tests/UserMessage.test.jsx +83 -0
- package/src/ChatBlock/tests/WebResultIcon.test.jsx +61 -0
- package/src/ChatBlock/tests/citations.test.js +114 -0
- package/src/ChatBlock/tests/messageProcessor.test.jsx +285 -1
- package/src/ChatBlock/tests/packetUtils.test.js +158 -0
- package/src/ChatBlock/tests/streamingService.test.js +467 -0
- package/src/ChatBlock/tests/useChatController.test.jsx +268 -0
- package/src/ChatBlock/tests/useChatStreaming.test.jsx +163 -0
- package/src/ChatBlock/tests/useMarked.test.jsx +107 -0
- package/src/ChatBlock/tests/useQualityMarkers.test.jsx +150 -0
- package/src/ChatBlock/tests/useScrollonStream.test.jsx +121 -0
- package/src/ChatBlock/tests/utils.test.jsx +241 -0
- package/src/ChatBlock/tests/withOnyxData.test.jsx +81 -0
- package/src/ChatBlock/utils/citations.ts +1 -1
- package/src/halloumi/generative.js +1 -0
- package/src/halloumi/generative.test.js +278 -0
- package/src/halloumi/middleware.test.js +69 -0
- package/src/index.js +1 -0
- package/src/middleware.js +21 -13
- package/src/middleware.test.js +221 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react-hooks';
|
|
2
|
+
import { PacketType } from '../types/streamingModels';
|
|
3
|
+
|
|
4
|
+
import { useChatController } from '../hooks/useChatController';
|
|
5
|
+
import { createChatSession } from '../services/streamingService';
|
|
6
|
+
|
|
7
|
+
// Mock the streaming service with configurable sendMessage behavior
|
|
8
|
+
const mockSendMessage = jest.fn(async function* () {
|
|
9
|
+
yield [];
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
jest.mock('../services/streamingService', () => ({
|
|
13
|
+
sendMessage: (...args) => mockSendMessage(...args),
|
|
14
|
+
createChatSession: jest.fn().mockResolvedValue('session-123'),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe('useChatController', () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
jest.restoreAllMocks();
|
|
20
|
+
mockSendMessage.mockImplementation(async function* () {
|
|
21
|
+
yield [];
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('initializes with correct default state', () => {
|
|
26
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
27
|
+
|
|
28
|
+
expect(result.current.messages).toEqual([]);
|
|
29
|
+
expect(result.current.isStreaming).toBe(false);
|
|
30
|
+
expect(result.current.isCancelled).toBe(false);
|
|
31
|
+
expect(typeof result.current.onSubmit).toBe('function');
|
|
32
|
+
expect(typeof result.current.clearChat).toBe('function');
|
|
33
|
+
expect(typeof result.current.cancelStreaming).toBe('function');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('initializes deep research based on config', () => {
|
|
37
|
+
const { result } = renderHook(() =>
|
|
38
|
+
useChatController({ personaId: 1, deepResearch: 'always_on' }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(result.current.isDeepResearchEnabled).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('initializes deep research as false when not configured', () => {
|
|
45
|
+
const { result } = renderHook(() =>
|
|
46
|
+
useChatController({ personaId: 1, deepResearch: 'disabled' }),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(result.current.isDeepResearchEnabled).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('initializes deep research with user_on', () => {
|
|
53
|
+
const { result } = renderHook(() =>
|
|
54
|
+
useChatController({ personaId: 1, deepResearch: 'user_on' }),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(result.current.isDeepResearchEnabled).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('creates a chat session on first submit', async () => {
|
|
61
|
+
createChatSession.mockResolvedValue('session-123');
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
64
|
+
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await result.current.onSubmit({ message: 'Hello' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(createChatSession).toHaveBeenCalledWith(1, 'Chat session');
|
|
70
|
+
expect(result.current.messages.length).toBeGreaterThan(0);
|
|
71
|
+
// First message should be a user message
|
|
72
|
+
expect(result.current.messages[0].type).toBe('user');
|
|
73
|
+
expect(result.current.messages[0].message).toBe('Hello');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not submit empty messages', async () => {
|
|
77
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await result.current.onSubmit({ message: ' ' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Should only have session creation but no messages since message is blank
|
|
84
|
+
expect(result.current.messages).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('clearChat resets all state', async () => {
|
|
88
|
+
createChatSession.mockResolvedValue('session-123');
|
|
89
|
+
|
|
90
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
await result.current.onSubmit({ message: 'Hello' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.current.messages.length).toBeGreaterThan(0);
|
|
97
|
+
|
|
98
|
+
act(() => {
|
|
99
|
+
result.current.clearChat();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.current.messages).toEqual([]);
|
|
103
|
+
expect(result.current.chatSessionId).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('cancelStreaming sets isCancelled to true', () => {
|
|
107
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
result.current.cancelStreaming();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.current.isCancelled).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('setIsDeepResearchEnabled toggles deep research', () => {
|
|
117
|
+
const { result } = renderHook(() =>
|
|
118
|
+
useChatController({ personaId: 1, deepResearch: 'user_off' }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(result.current.isDeepResearchEnabled).toBe(false);
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
result.current.setIsDeepResearchEnabled(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.current.isDeepResearchEnabled).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('handles session creation error gracefully', async () => {
|
|
131
|
+
createChatSession.mockRejectedValue(new Error('Session creation failed'));
|
|
132
|
+
|
|
133
|
+
const consoleSpy = jest
|
|
134
|
+
.spyOn(console, 'error')
|
|
135
|
+
.mockImplementation(() => {});
|
|
136
|
+
|
|
137
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
138
|
+
|
|
139
|
+
await act(async () => {
|
|
140
|
+
await result.current.onSubmit({ message: 'Hello' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
144
|
+
consoleSpy.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('reuses existing session on subsequent submits', async () => {
|
|
148
|
+
createChatSession.mockResolvedValue('session-123');
|
|
149
|
+
|
|
150
|
+
mockSendMessage.mockImplementation(async function* () {
|
|
151
|
+
yield [
|
|
152
|
+
{
|
|
153
|
+
ind: 1,
|
|
154
|
+
obj: {
|
|
155
|
+
type: PacketType.MESSAGE_START,
|
|
156
|
+
id: 'msg1',
|
|
157
|
+
content: 'Reply',
|
|
158
|
+
final_documents: null,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{ ind: 2, obj: { type: PacketType.STOP } },
|
|
162
|
+
];
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
await result.current.onSubmit({ message: 'First' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
createChatSession.mockClear();
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
await result.current.onSubmit({ message: 'Second' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Should not create a new session
|
|
178
|
+
expect(createChatSession).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('adds user message with deep research type when enabled', async () => {
|
|
182
|
+
createChatSession.mockResolvedValue('session-123');
|
|
183
|
+
|
|
184
|
+
const { result } = renderHook(() =>
|
|
185
|
+
useChatController({ personaId: 1, deepResearch: 'always_on' }),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await act(async () => {
|
|
189
|
+
await result.current.onSubmit({ message: 'Search deeply' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const userMsg = result.current.messages.find((m) => m.type === 'user');
|
|
193
|
+
expect(userMsg.researchType).toBe('DEEP');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('adds user message with fast research type when deep research is disabled', async () => {
|
|
197
|
+
createChatSession.mockResolvedValue('session-123');
|
|
198
|
+
|
|
199
|
+
const { result } = renderHook(() =>
|
|
200
|
+
useChatController({ personaId: 1, deepResearch: 'disabled' }),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await act(async () => {
|
|
204
|
+
await result.current.onSubmit({ message: 'Quick search' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const userMsg = result.current.messages.find((m) => m.type === 'user');
|
|
208
|
+
expect(userMsg.researchType).toBe('FAST');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('exposes onFetchRelatedQuestions callback', () => {
|
|
212
|
+
const { result } = renderHook(() =>
|
|
213
|
+
useChatController({ personaId: 1, enableQgen: true, qgenAsistantId: 2 }),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
expect(typeof result.current.onFetchRelatedQuestions).toBe('function');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('onFetchRelatedQuestions sets null relatedQuestions when deep research is on', async () => {
|
|
220
|
+
createChatSession.mockResolvedValue('session-123');
|
|
221
|
+
|
|
222
|
+
mockSendMessage.mockImplementation(async function* () {
|
|
223
|
+
yield [
|
|
224
|
+
{
|
|
225
|
+
ind: 1,
|
|
226
|
+
obj: {
|
|
227
|
+
type: PacketType.MESSAGE_START,
|
|
228
|
+
id: 'msg1',
|
|
229
|
+
content: 'Answer',
|
|
230
|
+
final_documents: null,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{ ind: 2, obj: { type: PacketType.STOP } },
|
|
234
|
+
];
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const { result } = renderHook(() =>
|
|
238
|
+
useChatController({
|
|
239
|
+
personaId: 1,
|
|
240
|
+
enableQgen: true,
|
|
241
|
+
qgenAsistantId: 2,
|
|
242
|
+
deepResearch: 'always_on',
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
await result.current.onSubmit({ message: 'Hello' });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await act(async () => {
|
|
251
|
+
await result.current.onFetchRelatedQuestions();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// When deep research is on, relatedQuestions should be set to null
|
|
255
|
+
const assistantMsg = result.current.messages.find(
|
|
256
|
+
(m) => m.type === 'assistant',
|
|
257
|
+
);
|
|
258
|
+
if (assistantMsg) {
|
|
259
|
+
expect(assistantMsg.relatedQuestions).toBeNull();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('exposes isFetchingRelatedQuestions state', () => {
|
|
264
|
+
const { result } = renderHook(() => useChatController({ personaId: 1 }));
|
|
265
|
+
|
|
266
|
+
expect(result.current.isFetchingRelatedQuestions).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react-hooks';
|
|
2
|
+
|
|
3
|
+
import { useChatStreaming } from '../hooks/useChatStreaming';
|
|
4
|
+
import { sendMessage } from '../services/streamingService';
|
|
5
|
+
import { PacketType } from '../types/streamingModels';
|
|
6
|
+
|
|
7
|
+
// Mock the streaming service
|
|
8
|
+
jest.mock('../services/streamingService', () => ({
|
|
9
|
+
sendMessage: jest.fn(),
|
|
10
|
+
createChatSession: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe('useChatStreaming', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('initializes with correct default state', () => {
|
|
19
|
+
const { result } = renderHook(() => useChatStreaming());
|
|
20
|
+
|
|
21
|
+
expect(result.current.isStreaming).toBe(false);
|
|
22
|
+
expect(result.current.currentMessage).toBeNull();
|
|
23
|
+
expect(typeof result.current.startStreaming).toBe('function');
|
|
24
|
+
expect(typeof result.current.cancelStreaming).toBe('function');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('starts and completes streaming', async () => {
|
|
28
|
+
const packets = [
|
|
29
|
+
{
|
|
30
|
+
ind: 1,
|
|
31
|
+
obj: { type: PacketType.MESSAGE_START, content: 'Hello' },
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
sendMessage.mockImplementation(async function* () {
|
|
36
|
+
yield packets;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const onMessageUpdate = jest.fn();
|
|
40
|
+
const onComplete = jest.fn();
|
|
41
|
+
|
|
42
|
+
const { result } = renderHook(() =>
|
|
43
|
+
useChatStreaming({ onMessageUpdate, onComplete }),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
await act(async () => {
|
|
47
|
+
await result.current.startStreaming(
|
|
48
|
+
{
|
|
49
|
+
message: 'test',
|
|
50
|
+
chatSessionId: 'session-1',
|
|
51
|
+
parentMessageId: null,
|
|
52
|
+
regenerate: false,
|
|
53
|
+
filters: null,
|
|
54
|
+
selectedDocumentIds: [],
|
|
55
|
+
},
|
|
56
|
+
1,
|
|
57
|
+
null,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.current.isStreaming).toBe(false);
|
|
62
|
+
expect(onMessageUpdate).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles streaming error', async () => {
|
|
66
|
+
sendMessage.mockImplementation(async function* () {
|
|
67
|
+
yield '';
|
|
68
|
+
throw new Error('Stream failed');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const onError = jest.fn();
|
|
72
|
+
|
|
73
|
+
const { result } = renderHook(() => useChatStreaming({ onError }));
|
|
74
|
+
|
|
75
|
+
await act(async () => {
|
|
76
|
+
await result.current.startStreaming(
|
|
77
|
+
{
|
|
78
|
+
message: 'test',
|
|
79
|
+
chatSessionId: 'session-1',
|
|
80
|
+
parentMessageId: null,
|
|
81
|
+
regenerate: false,
|
|
82
|
+
filters: null,
|
|
83
|
+
selectedDocumentIds: [],
|
|
84
|
+
},
|
|
85
|
+
1,
|
|
86
|
+
null,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result.current.isStreaming).toBe(false);
|
|
91
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error), expect.anything());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('cancelStreaming aborts the stream', async () => {
|
|
95
|
+
let resolveStream;
|
|
96
|
+
const streamPromise = new Promise((resolve) => {
|
|
97
|
+
resolveStream = resolve;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
sendMessage.mockImplementation(async function* () {
|
|
101
|
+
await streamPromise;
|
|
102
|
+
yield [];
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const { result } = renderHook(() => useChatStreaming());
|
|
106
|
+
|
|
107
|
+
// Start streaming without awaiting
|
|
108
|
+
act(() => {
|
|
109
|
+
result.current.startStreaming(
|
|
110
|
+
{
|
|
111
|
+
message: 'test',
|
|
112
|
+
chatSessionId: 'session-1',
|
|
113
|
+
parentMessageId: null,
|
|
114
|
+
regenerate: false,
|
|
115
|
+
filters: null,
|
|
116
|
+
selectedDocumentIds: [],
|
|
117
|
+
},
|
|
118
|
+
1,
|
|
119
|
+
null,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Cancel the stream
|
|
124
|
+
act(() => {
|
|
125
|
+
result.current.cancelStreaming();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.current.isStreaming).toBe(false);
|
|
129
|
+
|
|
130
|
+
// Cleanup
|
|
131
|
+
resolveStream();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not call onError for AbortError', async () => {
|
|
135
|
+
sendMessage.mockImplementation(async function* () {
|
|
136
|
+
yield '';
|
|
137
|
+
const error = new Error('Aborted');
|
|
138
|
+
error.name = 'AbortError';
|
|
139
|
+
throw error;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const onError = jest.fn();
|
|
143
|
+
|
|
144
|
+
const { result } = renderHook(() => useChatStreaming({ onError }));
|
|
145
|
+
|
|
146
|
+
await act(async () => {
|
|
147
|
+
await result.current.startStreaming(
|
|
148
|
+
{
|
|
149
|
+
message: 'test',
|
|
150
|
+
chatSessionId: 'session-1',
|
|
151
|
+
parentMessageId: null,
|
|
152
|
+
regenerate: false,
|
|
153
|
+
filters: null,
|
|
154
|
+
selectedDocumentIds: [],
|
|
155
|
+
},
|
|
156
|
+
1,
|
|
157
|
+
null,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(onError).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react-hooks';
|
|
2
|
+
import { useMarked } from '../hooks/useMarked';
|
|
3
|
+
|
|
4
|
+
describe('useMarked', () => {
|
|
5
|
+
const createMockLibs = () => ({
|
|
6
|
+
highlightJs: {
|
|
7
|
+
default: {
|
|
8
|
+
getLanguage: jest.fn((lang) => (lang === 'javascript' ? true : false)),
|
|
9
|
+
highlight: jest.fn((lang, code) => ({
|
|
10
|
+
value: `<highlighted>${code}</highlighted>`,
|
|
11
|
+
})),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
marked: {
|
|
15
|
+
marked: {
|
|
16
|
+
setOptions: jest.fn(),
|
|
17
|
+
parse: jest.fn((text) => Promise.resolve(`<p>${text}</p>`)),
|
|
18
|
+
},
|
|
19
|
+
Renderer: jest.fn().mockImplementation(function () {
|
|
20
|
+
this.paragraph = null;
|
|
21
|
+
this.list = null;
|
|
22
|
+
this.listitem = null;
|
|
23
|
+
this.code = null;
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns a parser function', () => {
|
|
29
|
+
const libs = createMockLibs();
|
|
30
|
+
const { result } = renderHook(() => useMarked(libs));
|
|
31
|
+
expect(result.current.parser).toBeDefined();
|
|
32
|
+
expect(typeof result.current.parser).toBe('function');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parser calls marked.parse', async () => {
|
|
36
|
+
const libs = createMockLibs();
|
|
37
|
+
const { result } = renderHook(() => useMarked(libs));
|
|
38
|
+
|
|
39
|
+
await result.current.parser('Hello world');
|
|
40
|
+
expect(libs.marked.marked.parse).toHaveBeenCalledWith('Hello world');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('sets up renderer with custom paragraph', () => {
|
|
44
|
+
const libs = createMockLibs();
|
|
45
|
+
renderHook(() => useMarked(libs));
|
|
46
|
+
|
|
47
|
+
// The renderer instance should have custom methods
|
|
48
|
+
const rendererInstance = libs.marked.Renderer.mock.instances[0];
|
|
49
|
+
expect(rendererInstance.paragraph).toBeDefined();
|
|
50
|
+
expect(rendererInstance.paragraph('text')).toBe('text\n');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sets up renderer with custom list', () => {
|
|
54
|
+
const libs = createMockLibs();
|
|
55
|
+
renderHook(() => useMarked(libs));
|
|
56
|
+
|
|
57
|
+
const rendererInstance = libs.marked.Renderer.mock.instances[0];
|
|
58
|
+
expect(rendererInstance.list('items')).toBe('items\n\n');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('sets up renderer with custom listitem', () => {
|
|
62
|
+
const libs = createMockLibs();
|
|
63
|
+
renderHook(() => useMarked(libs));
|
|
64
|
+
|
|
65
|
+
const rendererInstance = libs.marked.Renderer.mock.instances[0];
|
|
66
|
+
expect(rendererInstance.listitem('item text')).toBe('\n• item text');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('sets up renderer with custom code highlighting', () => {
|
|
70
|
+
const libs = createMockLibs();
|
|
71
|
+
renderHook(() => useMarked(libs));
|
|
72
|
+
|
|
73
|
+
const rendererInstance = libs.marked.Renderer.mock.instances[0];
|
|
74
|
+
const result = rendererInstance.code('const x = 1;', 'javascript');
|
|
75
|
+
|
|
76
|
+
expect(result).toContain('<pre');
|
|
77
|
+
expect(result).toContain('<highlighted>');
|
|
78
|
+
expect(libs.highlightJs.default.highlight).toHaveBeenCalledWith(
|
|
79
|
+
'javascript',
|
|
80
|
+
'const x = 1;',
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('falls back to plaintext for unknown languages', () => {
|
|
85
|
+
const libs = createMockLibs();
|
|
86
|
+
renderHook(() => useMarked(libs));
|
|
87
|
+
|
|
88
|
+
const rendererInstance = libs.marked.Renderer.mock.instances[0];
|
|
89
|
+
rendererInstance.code('some code', 'unknownlang');
|
|
90
|
+
|
|
91
|
+
expect(libs.highlightJs.default.highlight).toHaveBeenCalledWith(
|
|
92
|
+
'plaintext',
|
|
93
|
+
'some code',
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('calls setOptions with the renderer', () => {
|
|
98
|
+
const libs = createMockLibs();
|
|
99
|
+
renderHook(() => useMarked(libs));
|
|
100
|
+
|
|
101
|
+
expect(libs.marked.marked.setOptions).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
renderer: expect.any(Object),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react-hooks';
|
|
2
|
+
|
|
3
|
+
import { useQualityMarkers } from '../hooks/useQualityMarkers';
|
|
4
|
+
|
|
5
|
+
// Mock loadable before importing the hook
|
|
6
|
+
jest.mock('@loadable/component', () => {
|
|
7
|
+
const loadable = () => {
|
|
8
|
+
const MockComponent = ({ children }) => null;
|
|
9
|
+
return MockComponent;
|
|
10
|
+
};
|
|
11
|
+
loadable.lib = () => {
|
|
12
|
+
const MockLibComponent = () => null;
|
|
13
|
+
MockLibComponent.load = () =>
|
|
14
|
+
Promise.resolve({ captureException: jest.fn() });
|
|
15
|
+
return MockLibComponent;
|
|
16
|
+
};
|
|
17
|
+
return { __esModule: true, default: loadable };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('useQualityMarkers', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
global.fetch = jest.fn();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
jest.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null markers when doQualityControl is false', () => {
|
|
30
|
+
const { result } = renderHook(() =>
|
|
31
|
+
useQualityMarkers(false, 'test message', []),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(result.current.markers).toBeNull();
|
|
35
|
+
expect(result.current.isLoadingHalloumi).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns failure rationale when sources are empty', async () => {
|
|
39
|
+
let hookResult;
|
|
40
|
+
await act(async () => {
|
|
41
|
+
const { result } = renderHook(() =>
|
|
42
|
+
useQualityMarkers(true, 'test message', []),
|
|
43
|
+
);
|
|
44
|
+
// Wait a tick for the async useEffect handler to complete
|
|
45
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
46
|
+
hookResult = result;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(hookResult.current.markers).toBeDefined();
|
|
50
|
+
expect(hookResult.current.markers.claims[0].rationale).toBe(
|
|
51
|
+
'Answer cannot be verified due to empty sources.',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('fetches halloumi response when sources are provided', async () => {
|
|
56
|
+
global.fetch.mockResolvedValue({
|
|
57
|
+
json: () =>
|
|
58
|
+
Promise.resolve({
|
|
59
|
+
claims: [
|
|
60
|
+
{
|
|
61
|
+
startOffset: 0,
|
|
62
|
+
endOffset: 12,
|
|
63
|
+
score: 0.9,
|
|
64
|
+
rationale: 'Supported',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
segments: {},
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const sources = [{ halloumiContext: 'source text', id: '1' }];
|
|
72
|
+
|
|
73
|
+
const { result, waitForValueToChange } = renderHook(() =>
|
|
74
|
+
useQualityMarkers(true, 'test message', sources),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await waitForValueToChange(() => result.current.markers);
|
|
78
|
+
|
|
79
|
+
expect(result.current.markers).toBeDefined();
|
|
80
|
+
expect(result.current.markers.claims).toHaveLength(1);
|
|
81
|
+
expect(result.current.isLoadingHalloumi).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('retryHalloumi resets response', async () => {
|
|
85
|
+
global.fetch.mockResolvedValue({
|
|
86
|
+
json: () =>
|
|
87
|
+
Promise.resolve({
|
|
88
|
+
claims: [
|
|
89
|
+
{ startOffset: 0, endOffset: 12, score: 0.9, rationale: 'Ok' },
|
|
90
|
+
],
|
|
91
|
+
segments: {},
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const sources = [{ halloumiContext: 'source text', id: '1' }];
|
|
96
|
+
|
|
97
|
+
const { result, waitForValueToChange } = renderHook(() =>
|
|
98
|
+
useQualityMarkers(true, 'test message', sources),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await waitForValueToChange(() => result.current.markers);
|
|
102
|
+
expect(result.current.markers).not.toBeNull();
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
result.current.retryHalloumi();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.current.markers).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('filters out claims with special characters and low score', async () => {
|
|
112
|
+
global.fetch.mockResolvedValue({
|
|
113
|
+
json: () =>
|
|
114
|
+
Promise.resolve({
|
|
115
|
+
claims: [
|
|
116
|
+
{
|
|
117
|
+
startOffset: 0,
|
|
118
|
+
endOffset: 1,
|
|
119
|
+
score: 0.01,
|
|
120
|
+
rationale: 'Low',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
startOffset: 0,
|
|
124
|
+
endOffset: 12,
|
|
125
|
+
score: 0.9,
|
|
126
|
+
rationale: 'Ok',
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
segments: {},
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const sources = [{ halloumiContext: 'source text', id: '1' }];
|
|
134
|
+
const message = '* test message';
|
|
135
|
+
|
|
136
|
+
const { result, waitForValueToChange } = renderHook(() =>
|
|
137
|
+
useQualityMarkers(true, message, sources),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await waitForValueToChange(() => result.current.markers);
|
|
141
|
+
// Claims with special chars only and small score get filtered
|
|
142
|
+
expect(result.current.markers.claims.length).toBeGreaterThanOrEqual(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('provides retryHalloumi callback', () => {
|
|
146
|
+
const { result } = renderHook(() => useQualityMarkers(false, 'test', []));
|
|
147
|
+
|
|
148
|
+
expect(typeof result.current.retryHalloumi).toBe('function');
|
|
149
|
+
});
|
|
150
|
+
});
|