@semiont/react-ui 0.4.19 → 0.4.21
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/README.md +8 -5
- package/dist/{PdfAnnotationCanvas.client-CHDCGQBR.mjs → PdfAnnotationCanvas.client-6ZGFEN2N.mjs} +9 -13
- package/dist/PdfAnnotationCanvas.client-6ZGFEN2N.mjs.map +1 -0
- package/dist/TranslationManager-9Xj3MIWQ.d.mts +16 -0
- package/dist/chunk-KEDFYI6N.mjs +7788 -0
- package/dist/chunk-KEDFYI6N.mjs.map +1 -0
- package/dist/index.d.mts +171 -1140
- package/dist/index.mjs +3263 -13644
- package/dist/index.mjs.map +1 -1
- package/dist/test-utils.d.mts +46 -21
- package/dist/test-utils.mjs +2499 -87
- package/dist/test-utils.mjs.map +1 -1
- package/package.json +1 -2
- package/src/components/AnnotateReferencesProgressWidget.tsx +21 -28
- package/src/components/CodeMirrorRenderer.tsx +9 -11
- package/src/components/StatusDisplay.tsx +42 -16
- package/src/components/Toolbar.tsx +4 -4
- package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +34 -20
- package/src/components/__tests__/StatusDisplay.test.tsx +47 -64
- package/src/components/__tests__/Toolbar.test.tsx +4 -4
- package/src/components/annotation/AnnotateToolbar.tsx +8 -7
- package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +31 -77
- package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +0 -1
- package/src/components/image-annotation/AnnotationOverlay.tsx +12 -13
- package/src/components/image-annotation/SvgDrawingCanvas.tsx +7 -7
- package/src/components/modals/PermissionDeniedModal.tsx +11 -11
- package/src/components/modals/ReferenceWizardModal.tsx +14 -18
- package/src/components/modals/ResourceSearchModal.tsx +10 -6
- package/src/components/modals/SearchModal.tsx +10 -6
- package/src/components/modals/SessionExpiredModal.tsx +11 -11
- package/src/components/modals/__tests__/PermissionDeniedModal.test.tsx +7 -7
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +10 -8
- package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +10 -7
- package/src/components/modals/__tests__/SessionExpiredModal.test.tsx +5 -5
- package/src/components/navigation/CollapsibleResourceNavigation.tsx +10 -10
- package/src/components/navigation/ObservableLink.tsx +6 -6
- package/src/components/navigation/SimpleNavigation.tsx +4 -4
- package/src/components/navigation/__tests__/ObservableLink.test.tsx +4 -4
- package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +4 -4
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +9 -11
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +0 -1
- package/src/components/resource/AnnotateView.tsx +7 -6
- package/src/components/resource/AnnotationHistory.tsx +9 -12
- package/src/components/resource/BrowseView.tsx +8 -7
- package/src/components/resource/ResourceViewer.tsx +17 -25
- package/src/components/resource/__tests__/AnnotationHistory.test.tsx +54 -192
- package/src/components/resource/__tests__/BrowseView.test.tsx +34 -83
- package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +40 -31
- package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
- package/src/components/resource/panels/AssessmentPanel.tsx +19 -15
- package/src/components/resource/panels/AssistSection.tsx +11 -13
- package/src/components/resource/panels/CollaborationPanel.tsx +29 -7
- package/src/components/resource/panels/CommentEntry.tsx +5 -4
- package/src/components/resource/panels/CommentsPanel.tsx +9 -11
- package/src/components/resource/panels/HighlightEntry.tsx +5 -4
- package/src/components/resource/panels/HighlightPanel.tsx +10 -11
- package/src/components/resource/panels/ReferenceEntry.tsx +8 -8
- package/src/components/resource/panels/ReferencesPanel.tsx +13 -12
- package/src/components/resource/panels/ResourceInfoPanel.tsx +7 -6
- package/src/components/resource/panels/TagEntry.tsx +5 -4
- package/src/components/resource/panels/TaggingPanel.tsx +10 -16
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +3 -2
- package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +18 -52
- package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +51 -20
- package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +18 -56
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +0 -1
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +4 -5
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +153 -0
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +51 -106
- package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +15 -47
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +15 -47
- package/src/components/settings/SettingsPanel.tsx +8 -8
- package/src/components/settings/__tests__/SettingsPanel.test.tsx +12 -12
- package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
- package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
- package/src/features/admin-exchange/components/ImportCard.tsx +2 -6
- package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
- package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
- package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
- package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
- package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
- package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
- package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +5 -3
- package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -22
- package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +4 -3
- package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +38 -45
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +123 -192
- package/dist/KnowledgeBaseSessionContext-BNNunwzO.d.mts +0 -175
- package/dist/PdfAnnotationCanvas.client-CHDCGQBR.mjs.map +0 -1
- package/dist/chunk-OZICDVH7.mjs +0 -62
- package/dist/chunk-OZICDVH7.mjs.map +0 -1
- package/dist/chunk-R4CCMFJH.mjs +0 -877
- package/dist/chunk-R4CCMFJH.mjs.map +0 -1
- package/dist/chunk-VN5NY4SN.mjs +0 -200
- package/dist/chunk-VN5NY4SN.mjs.map +0 -1
- package/src/components/modals/ProposeEntitiesModal.tsx +0 -179
- package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +0 -129
- package/src/features/resource-viewer/__tests__/AnnotationCreationPending.test.tsx +0 -323
- package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +0 -245
- package/src/features/resource-viewer/__tests__/AnnotationProgressDismissal.test.tsx +0 -303
- package/src/features/resource-viewer/__tests__/BindFlowIntegration.test.tsx +0 -150
- package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +0 -243
- package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +0 -383
- package/src/features/resource-viewer/__tests__/ResourceMutations.test.tsx +0 -299
- package/src/features/resource-viewer/__tests__/ToastNotifications.test.tsx +0 -186
- package/src/features/resource-viewer/__tests__/YieldFlowIntegration.test.tsx +0 -429
- package/src/features/resource-viewer/__tests__/annotation-progress-flow.test.tsx +0 -348
|
@@ -1,383 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Layer 3: Feature Integration Test - Detection Flow Architecture
|
|
3
|
-
*
|
|
4
|
-
* Tests the COMPLETE detection flow with real component composition:
|
|
5
|
-
* - EventBusProvider (REAL)
|
|
6
|
-
* - ApiClientProvider (REAL, with MOCKED client)
|
|
7
|
-
* - useMarkFlow (REAL)
|
|
8
|
-
* - useBindFlow (REAL)
|
|
9
|
-
* - useEventSubscriptions (REAL)
|
|
10
|
-
*
|
|
11
|
-
* This test focuses on ARCHITECTURE and EVENT WIRING:
|
|
12
|
-
* - Verifies API called exactly ONCE (catches duplicate subscriptions)
|
|
13
|
-
* - Tests event propagation through the event bus
|
|
14
|
-
* - Validates different motivations call correct API methods
|
|
15
|
-
* - Ensures multiple event listeners don't cause duplicate API calls
|
|
16
|
-
*
|
|
17
|
-
* COMPLEMENTARY TEST: See detection-progress-flow.test.tsx for UI/UX testing
|
|
18
|
-
* - That test verifies the USER EXPERIENCE (button clicks, progress display)
|
|
19
|
-
* - This test verifies the SYSTEM ARCHITECTURE (event wiring, API calls)
|
|
20
|
-
*
|
|
21
|
-
* NO BACKEND SERVER - only mocked API client boundary
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import React from 'react';
|
|
25
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
26
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
27
|
-
import { act } from 'react';
|
|
28
|
-
import { useMarkFlow } from '../../../hooks/useMarkFlow';
|
|
29
|
-
import { EventBusProvider, useEventBus } from '../../../contexts/EventBusContext';
|
|
30
|
-
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
31
|
-
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
32
|
-
import { SemiontApiClient } from '@semiont/api-client';
|
|
33
|
-
import type { Motivation } from '@semiont/core';
|
|
34
|
-
import { resourceId } from '@semiont/core';
|
|
35
|
-
import type { Emitter } from 'mitt';
|
|
36
|
-
|
|
37
|
-
// Mock Toast module to prevent "useToast must be used within a ToastProvider" errors
|
|
38
|
-
vi.mock('../../../components/Toast', () => ({
|
|
39
|
-
useToast: () => ({
|
|
40
|
-
showSuccess: vi.fn(),
|
|
41
|
-
showError: vi.fn(),
|
|
42
|
-
showInfo: vi.fn(),
|
|
43
|
-
showWarning: vi.fn(),
|
|
44
|
-
}),
|
|
45
|
-
}));
|
|
46
|
-
import type { EventMap } from '@semiont/core';
|
|
47
|
-
|
|
48
|
-
describe('Detection Flow - Feature Integration', () => {
|
|
49
|
-
let markReferencesSpy: any;
|
|
50
|
-
let markHighlightsSpy: any;
|
|
51
|
-
let detectCommentsSpy: any;
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
vi.clearAllMocks();
|
|
55
|
-
|
|
56
|
-
// Spy on SemiontApiClient prototype HTTP methods (namespace methods call these)
|
|
57
|
-
markReferencesSpy = vi.spyOn(SemiontApiClient.prototype, 'annotateReferences').mockResolvedValue({ correlationId: 'c1', jobId: 'j1' });
|
|
58
|
-
markHighlightsSpy = vi.spyOn(SemiontApiClient.prototype, 'annotateHighlights').mockResolvedValue({ correlationId: 'c1', jobId: 'j1' });
|
|
59
|
-
detectCommentsSpy = vi.spyOn(SemiontApiClient.prototype, 'annotateComments').mockResolvedValue({ correlationId: 'c1', jobId: 'j1' });
|
|
60
|
-
vi.spyOn(SemiontApiClient.prototype, 'annotateAssessments').mockResolvedValue({ correlationId: 'c1', jobId: 'j1' });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
afterEach(() => {
|
|
64
|
-
vi.restoreAllMocks();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('should call annotateReferences exactly ONCE when detection starts (not twice)', async () => {
|
|
68
|
-
const testId = resourceId('test-resource');
|
|
69
|
-
|
|
70
|
-
// Render with real component composition
|
|
71
|
-
const { emitDetectionStart } = renderDetectionFlow(testId);
|
|
72
|
-
|
|
73
|
-
// Trigger detection for linking (uses annotateReferences)
|
|
74
|
-
act(() => {
|
|
75
|
-
emitDetectionStart('linking', {
|
|
76
|
-
entityTypes: ['Person', 'Organization'],
|
|
77
|
-
includeDescriptiveReferences: false
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// CRITICAL ASSERTION: API called exactly once (not twice!)
|
|
82
|
-
// This would FAIL if useBindFlow was called in multiple places
|
|
83
|
-
await waitFor(() => {
|
|
84
|
-
expect(markReferencesSpy).toHaveBeenCalledTimes(1);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// Verify correct parameters (eventBus is passed but we don't need to verify its exact value)
|
|
88
|
-
expect(markReferencesSpy).toHaveBeenCalledWith(
|
|
89
|
-
testId,
|
|
90
|
-
{
|
|
91
|
-
entityTypes: ['Person', 'Organization'],
|
|
92
|
-
includeDescriptiveReferences: false,
|
|
93
|
-
},
|
|
94
|
-
expect.objectContaining({ auth: undefined })
|
|
95
|
-
);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('should propagate SSE progress events to useMarkFlow state', async () => {
|
|
99
|
-
const testId = resourceId('test-resource');
|
|
100
|
-
|
|
101
|
-
// Render with state observer
|
|
102
|
-
const { emitDetectionStart, getEventBus } = renderDetectionFlow(testId);
|
|
103
|
-
|
|
104
|
-
// Start detection
|
|
105
|
-
act(() => {
|
|
106
|
-
emitDetectionStart('linking', {
|
|
107
|
-
entityTypes: ['Person']
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// Wait for stream to be created
|
|
112
|
-
await waitFor(() => {
|
|
113
|
-
expect(markReferencesSpy).toHaveBeenCalled();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// Simulate SSE progress event being emitted to EventBus (how SSE actually works now)
|
|
117
|
-
act(() => {
|
|
118
|
-
getEventBus().get('mark:progress').next({
|
|
119
|
-
status: 'scanning',
|
|
120
|
-
message: 'Scanning for Person...',
|
|
121
|
-
currentEntityType: 'Person',
|
|
122
|
-
totalEntityTypes: 1,
|
|
123
|
-
processedEntityTypes: 0,
|
|
124
|
-
foundCount: 5,
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// Verify progress propagated to UI
|
|
129
|
-
await waitFor(() => {
|
|
130
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Scanning for Person...');
|
|
131
|
-
expect(screen.getByTestId('detecting')).toHaveTextContent('linking');
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('should handle multiple progress updates correctly', async () => {
|
|
136
|
-
const testId = resourceId('test-resource');
|
|
137
|
-
const { emitDetectionStart, getEventBus } = renderDetectionFlow(testId);
|
|
138
|
-
|
|
139
|
-
// Start detection
|
|
140
|
-
act(() => {
|
|
141
|
-
emitDetectionStart('highlighting', {
|
|
142
|
-
instructions: 'Find important passages'
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
await waitFor(() => {
|
|
147
|
-
expect(markHighlightsSpy).toHaveBeenCalledTimes(1);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// First progress update via EventBus
|
|
151
|
-
act(() => {
|
|
152
|
-
getEventBus().get('mark:progress').next({
|
|
153
|
-
status: 'started',
|
|
154
|
-
message: 'Starting analysis...',
|
|
155
|
-
percentage: 0,
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
await waitFor(() => {
|
|
160
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Starting analysis...');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Second progress update via EventBus
|
|
164
|
-
act(() => {
|
|
165
|
-
getEventBus().get('mark:progress').next({
|
|
166
|
-
status: 'analyzing',
|
|
167
|
-
message: 'Analyzing text...',
|
|
168
|
-
percentage: 50,
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
await waitFor(() => {
|
|
173
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Analyzing text...');
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// Final progress update via EventBus
|
|
177
|
-
act(() => {
|
|
178
|
-
getEventBus().get('mark:progress').next({
|
|
179
|
-
status: 'complete',
|
|
180
|
-
message: 'Created 14 highlights',
|
|
181
|
-
percentage: 100,
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
await waitFor(() => {
|
|
186
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Created 14 highlights');
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('should keep progress visible after detection completes', async () => {
|
|
191
|
-
const testId = resourceId('test-resource');
|
|
192
|
-
const { emitDetectionStart, getEventBus } = renderDetectionFlow(testId);
|
|
193
|
-
|
|
194
|
-
// Start detection
|
|
195
|
-
act(() => {
|
|
196
|
-
emitDetectionStart('highlighting', { instructions: 'Test' });
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
await waitFor(() => {
|
|
200
|
-
expect(screen.getByTestId('detecting')).toHaveTextContent('highlighting');
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// Send final progress via EventBus
|
|
204
|
-
act(() => {
|
|
205
|
-
getEventBus().get('mark:progress').next({
|
|
206
|
-
status: 'complete',
|
|
207
|
-
message: 'Created 14 highlights',
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
await waitFor(() => {
|
|
212
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Created 14 highlights');
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
// Emit completion event
|
|
216
|
-
act(() => {
|
|
217
|
-
getEventBus().get('mark:assist-finished').next({ motivation: 'highlighting' });
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
// Verify: detecting flag cleared BUT progress still visible
|
|
221
|
-
await waitFor(() => {
|
|
222
|
-
expect(screen.getByTestId('detecting')).toHaveTextContent('none');
|
|
223
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Created 14 highlights');
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('should clear progress on detection failure', async () => {
|
|
228
|
-
const testId = resourceId('test-resource');
|
|
229
|
-
const { emitDetectionStart, getEventBus } = renderDetectionFlow(testId);
|
|
230
|
-
|
|
231
|
-
// Start detection
|
|
232
|
-
act(() => {
|
|
233
|
-
emitDetectionStart('linking', { entityTypes: ['Person'] });
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// Add some progress via EventBus
|
|
237
|
-
act(() => {
|
|
238
|
-
getEventBus().get('mark:progress').next({
|
|
239
|
-
status: 'scanning',
|
|
240
|
-
message: 'Scanning...',
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
await waitFor(() => {
|
|
245
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('Scanning...');
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// Emit failure
|
|
249
|
-
act(() => {
|
|
250
|
-
getEventBus().get('mark:assist-failed').next({
|
|
251
|
-
type: 'job.failed' as const,
|
|
252
|
-
resourceId: 'test-resource' as any,
|
|
253
|
-
userId: 'user' as any,
|
|
254
|
-
id: 'evt-1' as any,
|
|
255
|
-
timestamp: new Date().toISOString(),
|
|
256
|
-
version: 1,
|
|
257
|
-
payload: {
|
|
258
|
-
jobId: 'job-1' as any,
|
|
259
|
-
jobType: 'detection',
|
|
260
|
-
error: 'Network error',
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// Verify: both detecting and progress cleared
|
|
266
|
-
await waitFor(() => {
|
|
267
|
-
expect(screen.getByTestId('detecting')).toHaveTextContent('none');
|
|
268
|
-
expect(screen.getByTestId('progress')).toHaveTextContent('No progress');
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it('should handle different detection motivations with correct API calls', async () => {
|
|
273
|
-
const testId = resourceId('test-resource');
|
|
274
|
-
const { emitDetectionStart } = renderDetectionFlow(testId);
|
|
275
|
-
|
|
276
|
-
// Test highlighting
|
|
277
|
-
act(() => {
|
|
278
|
-
emitDetectionStart('highlighting', { instructions: 'Find important text' });
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
await waitFor(() => {
|
|
282
|
-
expect(markHighlightsSpy).toHaveBeenCalledTimes(1);
|
|
283
|
-
expect(markHighlightsSpy).toHaveBeenCalledWith(testId, {
|
|
284
|
-
instructions: 'Find important text',
|
|
285
|
-
}, expect.objectContaining({ auth: undefined }));
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// Reset for next test
|
|
289
|
-
vi.clearAllMocks();
|
|
290
|
-
detectCommentsSpy.mockResolvedValue({ correlationId: 'c2', jobId: 'j2' });
|
|
291
|
-
|
|
292
|
-
// Test commenting
|
|
293
|
-
act(() => {
|
|
294
|
-
emitDetectionStart('commenting', {
|
|
295
|
-
instructions: 'Add helpful comments',
|
|
296
|
-
tone: 'educational'
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
await waitFor(() => {
|
|
301
|
-
expect(detectCommentsSpy).toHaveBeenCalledTimes(1);
|
|
302
|
-
expect(detectCommentsSpy).toHaveBeenCalledWith(testId, {
|
|
303
|
-
instructions: 'Add helpful comments',
|
|
304
|
-
tone: 'educational',
|
|
305
|
-
}, expect.objectContaining({ auth: undefined }));
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('should only call API once even with multiple event listeners', async () => {
|
|
310
|
-
const testId = resourceId('test-resource');
|
|
311
|
-
|
|
312
|
-
// This test specifically catches the duplicate useBindFlow bug
|
|
313
|
-
// If multiple components call useBindFlow, we'll see multiple API calls
|
|
314
|
-
const { emitDetectionStart, getEventBus } = renderDetectionFlow(testId);
|
|
315
|
-
|
|
316
|
-
// Add an additional event listener (simulating multiple subscribers)
|
|
317
|
-
const additionalListener = vi.fn();
|
|
318
|
-
const subscription = getEventBus().get('mark:assist-request').subscribe(additionalListener);
|
|
319
|
-
|
|
320
|
-
// Trigger detection
|
|
321
|
-
act(() => {
|
|
322
|
-
emitDetectionStart('linking', { entityTypes: ['Person'] });
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
// Wait for operation to complete
|
|
326
|
-
await waitFor(() => {
|
|
327
|
-
expect(markReferencesSpy).toHaveBeenCalled();
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// VERIFY: API called exactly once, even though multiple listeners exist
|
|
331
|
-
expect(markReferencesSpy).toHaveBeenCalledTimes(1);
|
|
332
|
-
|
|
333
|
-
// VERIFY: Our additional listener was called (events work)
|
|
334
|
-
expect(additionalListener).toHaveBeenCalledTimes(1);
|
|
335
|
-
|
|
336
|
-
subscription.unsubscribe();
|
|
337
|
-
});
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Helper: Render useMarkFlow hook with real component composition
|
|
342
|
-
* Returns methods to interact with the rendered component
|
|
343
|
-
*/
|
|
344
|
-
function renderDetectionFlow(testId: string) {
|
|
345
|
-
let eventBusInstance: Emitter<EventMap>;
|
|
346
|
-
|
|
347
|
-
// Component to capture EventBus instance
|
|
348
|
-
function EventBusCapture() {
|
|
349
|
-
eventBusInstance = useEventBus();
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Test harness component that uses the hook
|
|
354
|
-
function DetectionFlowTestHarness() {
|
|
355
|
-
const { progress, assistingMotivation } = useMarkFlow(testId as any);
|
|
356
|
-
return (
|
|
357
|
-
<div>
|
|
358
|
-
<div data-testid="detecting">{assistingMotivation || 'none'}</div>
|
|
359
|
-
<div data-testid="progress">
|
|
360
|
-
{progress?.message || 'No progress'}
|
|
361
|
-
</div>
|
|
362
|
-
</div>
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
render(
|
|
367
|
-
<EventBusProvider>
|
|
368
|
-
<AuthTokenProvider token={null}>
|
|
369
|
-
<ApiClientProvider baseUrl="http://localhost:4000">
|
|
370
|
-
<EventBusCapture />
|
|
371
|
-
<DetectionFlowTestHarness />
|
|
372
|
-
</ApiClientProvider>
|
|
373
|
-
</AuthTokenProvider>
|
|
374
|
-
</EventBusProvider>
|
|
375
|
-
);
|
|
376
|
-
|
|
377
|
-
return {
|
|
378
|
-
emitDetectionStart: (motivation: Motivation, options: any) => {
|
|
379
|
-
eventBusInstance.get('mark:assist-request').next({ motivation, options });
|
|
380
|
-
},
|
|
381
|
-
getEventBus: () => eventBusInstance,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test: resource mutations must be hoisted to component top level
|
|
3
|
-
*
|
|
4
|
-
* Bug: handleResourceClone (and handleResourceArchive / handleResourceUnarchive)
|
|
5
|
-
* called useMutation() inside a useCallback, violating the Rules of Hooks.
|
|
6
|
-
* React does not re-execute memoized callbacks on every render, so the mutation
|
|
7
|
-
* object was never properly initialised and .mutateAsync() threw immediately,
|
|
8
|
-
* always landing in the catch block and showing "Failed to generate clone link".
|
|
9
|
-
*
|
|
10
|
-
* Fix: the two mutations (updateMutation, generateCloneTokenMutation) are now
|
|
11
|
-
* called unconditionally at the top level of ResourceViewerPage, and the
|
|
12
|
-
* resulting objects are threaded into the useCallback dependency arrays.
|
|
13
|
-
*
|
|
14
|
-
* This test suite uses a minimal harness that:
|
|
15
|
-
* - Mounts the REAL useResources() hook (which calls useMutation internally)
|
|
16
|
-
* - Wires up a REAL EventBus and subscribes the same handlers as ResourceViewerPage
|
|
17
|
-
* - Spies on SemiontApiClient.prototype to intercept API calls
|
|
18
|
-
*
|
|
19
|
-
* It confirms that each event-driven mutation calls the API exactly once,
|
|
20
|
-
* and that the clipboard is written with the correct token URL for clone.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import React, { useCallback } from 'react';
|
|
24
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
25
|
-
import { render, waitFor } from '@testing-library/react';
|
|
26
|
-
import { act } from 'react';
|
|
27
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
28
|
-
import { SemiontApiClient } from '@semiont/api-client';
|
|
29
|
-
import { resourceId, accessToken } from '@semiont/core';
|
|
30
|
-
import { EventBusProvider, useEventBus } from '../../../contexts/EventBusContext';
|
|
31
|
-
import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
|
|
32
|
-
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
33
|
-
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
34
|
-
import { useResources } from '../../../lib/api-hooks';
|
|
35
|
-
import type { EventMap, EventBus } from '@semiont/core';
|
|
36
|
-
|
|
37
|
-
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
const TEST_URI = resourceId('test-resource');
|
|
40
|
-
const TEST_TOKEN = 'test-auth-token-123';
|
|
41
|
-
const BASE_URL = 'http://localhost:4000';
|
|
42
|
-
const CLONE_TOKEN = 'generated-clone-token-xyz';
|
|
43
|
-
|
|
44
|
-
// ─── Harness ──────────────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Minimal harness that replicates the three mutation-backed event handlers
|
|
48
|
-
* from ResourceViewerPage using the REAL useResources hook.
|
|
49
|
-
*
|
|
50
|
-
* The critical invariant under test: useMutation() is called at hook level
|
|
51
|
-
* (inside useResources), not inside the useCallback bodies.
|
|
52
|
-
*/
|
|
53
|
-
function ResourceMutationHarness({ onEventBus }: { onEventBus: (eventBus: EventBus) => void }) {
|
|
54
|
-
const eventBus = useEventBus();
|
|
55
|
-
|
|
56
|
-
// Capture the eventBus for the test to emit events
|
|
57
|
-
React.useEffect(() => {
|
|
58
|
-
onEventBus(eventBus);
|
|
59
|
-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
60
|
-
|
|
61
|
-
// Real hook — mutations are initialised at the top level of useResources()
|
|
62
|
-
const resources = useResources();
|
|
63
|
-
|
|
64
|
-
// Mutations hoisted to this component's top level — same pattern as ResourceViewerPage fix
|
|
65
|
-
const updateMutation = resources.update.useMutation();
|
|
66
|
-
const generateCloneTokenMutation = resources.generateCloneToken.useMutation();
|
|
67
|
-
|
|
68
|
-
const handleResourceArchive = useCallback(async () => {
|
|
69
|
-
await updateMutation.mutateAsync({ id: TEST_URI, data: { archived: true } });
|
|
70
|
-
}, [updateMutation]);
|
|
71
|
-
|
|
72
|
-
const handleResourceUnarchive = useCallback(async () => {
|
|
73
|
-
await updateMutation.mutateAsync({ id: TEST_URI, data: { archived: false } });
|
|
74
|
-
}, [updateMutation]);
|
|
75
|
-
|
|
76
|
-
const handleResourceClone = useCallback(async () => {
|
|
77
|
-
const result = await generateCloneTokenMutation.mutateAsync(TEST_URI);
|
|
78
|
-
const cloneUrl = `${window.location.origin}/know/clone?token=${result.token}`;
|
|
79
|
-
await navigator.clipboard.writeText(cloneUrl);
|
|
80
|
-
}, [generateCloneTokenMutation]);
|
|
81
|
-
|
|
82
|
-
useEventSubscriptions({
|
|
83
|
-
'mark:archive': handleResourceArchive,
|
|
84
|
-
'mark:unarchive': handleResourceUnarchive,
|
|
85
|
-
'yield:clone': handleResourceClone,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ─── Test setup ───────────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
function renderHarness() {
|
|
94
|
-
let capturedEventBus: EventBus | null = null;
|
|
95
|
-
|
|
96
|
-
const queryClient = new QueryClient({
|
|
97
|
-
defaultOptions: {
|
|
98
|
-
queries: { retry: false },
|
|
99
|
-
mutations: { retry: false },
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
render(
|
|
104
|
-
<EventBusProvider>
|
|
105
|
-
<AuthTokenProvider token={TEST_TOKEN}>
|
|
106
|
-
<ApiClientProvider baseUrl={BASE_URL}>
|
|
107
|
-
<QueryClientProvider client={queryClient}>
|
|
108
|
-
<ResourceMutationHarness onEventBus={(eventBus) => { capturedEventBus = eventBus; }} />
|
|
109
|
-
</QueryClientProvider>
|
|
110
|
-
</ApiClientProvider>
|
|
111
|
-
</AuthTokenProvider>
|
|
112
|
-
</EventBusProvider>
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
const emit = <K extends keyof EventMap>(event: K, payload: EventMap[K]) => {
|
|
116
|
-
act(() => { capturedEventBus!.get(event).next(payload); });
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
return { emit };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
123
|
-
|
|
124
|
-
describe('Resource mutations — hooks hoisted to top level', () => {
|
|
125
|
-
let generateCloneTokenSpy: ReturnType<typeof vi.spyOn>;
|
|
126
|
-
let updateResourceSpy: ReturnType<typeof vi.spyOn>;
|
|
127
|
-
let writeTextSpy: ReturnType<typeof vi.fn>;
|
|
128
|
-
|
|
129
|
-
beforeEach(() => {
|
|
130
|
-
vi.clearAllMocks();
|
|
131
|
-
|
|
132
|
-
generateCloneTokenSpy = vi
|
|
133
|
-
.spyOn(SemiontApiClient.prototype, 'generateCloneToken')
|
|
134
|
-
.mockResolvedValue({ token: CLONE_TOKEN } as any);
|
|
135
|
-
|
|
136
|
-
updateResourceSpy = vi
|
|
137
|
-
.spyOn(SemiontApiClient.prototype, 'updateResource')
|
|
138
|
-
.mockResolvedValue({ resource: {} } as any);
|
|
139
|
-
|
|
140
|
-
// jsdom has no clipboard — install a writable spy
|
|
141
|
-
writeTextSpy = vi.fn().mockResolvedValue(undefined);
|
|
142
|
-
Object.defineProperty(navigator, 'clipboard', {
|
|
143
|
-
value: { writeText: writeTextSpy },
|
|
144
|
-
configurable: true,
|
|
145
|
-
writable: true,
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
afterEach(() => {
|
|
150
|
-
vi.restoreAllMocks();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// ── Clone ──────────────────────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
it('calls generateCloneToken API when yield:clone event fires', async () => {
|
|
156
|
-
const { emit } = renderHarness();
|
|
157
|
-
|
|
158
|
-
await act(async () => {
|
|
159
|
-
emit('yield:clone', undefined);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
await waitFor(() => {
|
|
163
|
-
expect(generateCloneTokenSpy).toHaveBeenCalledTimes(1);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('passes the resource URI to generateCloneToken', async () => {
|
|
168
|
-
const { emit } = renderHarness();
|
|
169
|
-
|
|
170
|
-
await act(async () => {
|
|
171
|
-
emit('yield:clone', undefined);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
await waitFor(() => {
|
|
175
|
-
expect(generateCloneTokenSpy).toHaveBeenCalledWith(
|
|
176
|
-
TEST_URI,
|
|
177
|
-
expect.anything(),
|
|
178
|
-
);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('passes auth token to generateCloneToken', async () => {
|
|
183
|
-
const { emit } = renderHarness();
|
|
184
|
-
|
|
185
|
-
await act(async () => {
|
|
186
|
-
emit('yield:clone', undefined);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
await waitFor(() => {
|
|
190
|
-
expect(generateCloneTokenSpy).toHaveBeenCalledWith(
|
|
191
|
-
TEST_URI,
|
|
192
|
-
expect.objectContaining({ auth: accessToken(TEST_TOKEN) })
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it('writes a clone URL containing the returned token to the clipboard', async () => {
|
|
198
|
-
const { emit } = renderHarness();
|
|
199
|
-
|
|
200
|
-
await act(async () => {
|
|
201
|
-
emit('yield:clone', undefined);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
await waitFor(() => {
|
|
205
|
-
expect(writeTextSpy).toHaveBeenCalledTimes(1);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
const writtenUrl: string = writeTextSpy.mock.calls[0][0];
|
|
209
|
-
expect(writtenUrl).toContain(CLONE_TOKEN);
|
|
210
|
-
expect(writtenUrl).toContain('/know/clone?token=');
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('does NOT call updateResource when yield:clone fires', async () => {
|
|
214
|
-
const { emit } = renderHarness();
|
|
215
|
-
|
|
216
|
-
await act(async () => {
|
|
217
|
-
emit('yield:clone', undefined);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
await waitFor(() => {
|
|
221
|
-
expect(generateCloneTokenSpy).toHaveBeenCalledTimes(1);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
expect(updateResourceSpy).not.toHaveBeenCalled();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
// ── Archive ────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
it('calls updateResource with archived:true when mark:archive fires', async () => {
|
|
230
|
-
const { emit } = renderHarness();
|
|
231
|
-
|
|
232
|
-
await act(async () => {
|
|
233
|
-
emit('mark:archive', undefined);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
await waitFor(() => {
|
|
237
|
-
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
expect(updateResourceSpy).toHaveBeenCalledWith(
|
|
241
|
-
TEST_URI,
|
|
242
|
-
expect.objectContaining({ archived: true }),
|
|
243
|
-
expect.anything(),
|
|
244
|
-
);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('does NOT call generateCloneToken when mark:archive fires', async () => {
|
|
248
|
-
const { emit } = renderHarness();
|
|
249
|
-
|
|
250
|
-
await act(async () => {
|
|
251
|
-
emit('mark:archive', undefined);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
await waitFor(() => {
|
|
255
|
-
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
expect(generateCloneTokenSpy).not.toHaveBeenCalled();
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// ── Unarchive ──────────────────────────────────────────────────────────────
|
|
262
|
-
|
|
263
|
-
it('calls updateResource with archived:false when mark:unarchive fires', async () => {
|
|
264
|
-
const { emit } = renderHarness();
|
|
265
|
-
|
|
266
|
-
await act(async () => {
|
|
267
|
-
emit('mark:unarchive', undefined);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
await waitFor(() => {
|
|
271
|
-
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
expect(updateResourceSpy).toHaveBeenCalledWith(
|
|
275
|
-
TEST_URI,
|
|
276
|
-
expect.objectContaining({ archived: false }),
|
|
277
|
-
expect.anything(),
|
|
278
|
-
);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// ── Isolation ─────────────────────────────────────────────────────────────
|
|
282
|
-
|
|
283
|
-
it('mark:archive and yield:clone events each call their own API exactly once', async () => {
|
|
284
|
-
const { emit } = renderHarness();
|
|
285
|
-
|
|
286
|
-
await act(async () => {
|
|
287
|
-
emit('mark:archive', undefined);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
await act(async () => {
|
|
291
|
-
emit('yield:clone', undefined);
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
await waitFor(() => {
|
|
295
|
-
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
296
|
-
expect(generateCloneTokenSpy).toHaveBeenCalledTimes(1);
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
});
|