@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.
Files changed (108) hide show
  1. package/README.md +8 -5
  2. package/dist/{PdfAnnotationCanvas.client-CHDCGQBR.mjs → PdfAnnotationCanvas.client-6ZGFEN2N.mjs} +9 -13
  3. package/dist/PdfAnnotationCanvas.client-6ZGFEN2N.mjs.map +1 -0
  4. package/dist/TranslationManager-9Xj3MIWQ.d.mts +16 -0
  5. package/dist/chunk-KEDFYI6N.mjs +7788 -0
  6. package/dist/chunk-KEDFYI6N.mjs.map +1 -0
  7. package/dist/index.d.mts +171 -1140
  8. package/dist/index.mjs +3263 -13644
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/test-utils.d.mts +46 -21
  11. package/dist/test-utils.mjs +2499 -87
  12. package/dist/test-utils.mjs.map +1 -1
  13. package/package.json +1 -2
  14. package/src/components/AnnotateReferencesProgressWidget.tsx +21 -28
  15. package/src/components/CodeMirrorRenderer.tsx +9 -11
  16. package/src/components/StatusDisplay.tsx +42 -16
  17. package/src/components/Toolbar.tsx +4 -4
  18. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +34 -20
  19. package/src/components/__tests__/StatusDisplay.test.tsx +47 -64
  20. package/src/components/__tests__/Toolbar.test.tsx +4 -4
  21. package/src/components/annotation/AnnotateToolbar.tsx +8 -7
  22. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +31 -77
  23. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +0 -1
  24. package/src/components/image-annotation/AnnotationOverlay.tsx +12 -13
  25. package/src/components/image-annotation/SvgDrawingCanvas.tsx +7 -7
  26. package/src/components/modals/PermissionDeniedModal.tsx +11 -11
  27. package/src/components/modals/ReferenceWizardModal.tsx +14 -18
  28. package/src/components/modals/ResourceSearchModal.tsx +10 -6
  29. package/src/components/modals/SearchModal.tsx +10 -6
  30. package/src/components/modals/SessionExpiredModal.tsx +11 -11
  31. package/src/components/modals/__tests__/PermissionDeniedModal.test.tsx +7 -7
  32. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +10 -8
  33. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +10 -7
  34. package/src/components/modals/__tests__/SessionExpiredModal.test.tsx +5 -5
  35. package/src/components/navigation/CollapsibleResourceNavigation.tsx +10 -10
  36. package/src/components/navigation/ObservableLink.tsx +6 -6
  37. package/src/components/navigation/SimpleNavigation.tsx +4 -4
  38. package/src/components/navigation/__tests__/ObservableLink.test.tsx +4 -4
  39. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +4 -4
  40. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +9 -11
  41. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +0 -1
  42. package/src/components/resource/AnnotateView.tsx +7 -6
  43. package/src/components/resource/AnnotationHistory.tsx +9 -12
  44. package/src/components/resource/BrowseView.tsx +8 -7
  45. package/src/components/resource/ResourceViewer.tsx +17 -25
  46. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +54 -192
  47. package/src/components/resource/__tests__/BrowseView.test.tsx +34 -83
  48. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +40 -31
  49. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  50. package/src/components/resource/panels/AssessmentPanel.tsx +19 -15
  51. package/src/components/resource/panels/AssistSection.tsx +11 -13
  52. package/src/components/resource/panels/CollaborationPanel.tsx +29 -7
  53. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  54. package/src/components/resource/panels/CommentsPanel.tsx +9 -11
  55. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  56. package/src/components/resource/panels/HighlightPanel.tsx +10 -11
  57. package/src/components/resource/panels/ReferenceEntry.tsx +8 -8
  58. package/src/components/resource/panels/ReferencesPanel.tsx +13 -12
  59. package/src/components/resource/panels/ResourceInfoPanel.tsx +7 -6
  60. package/src/components/resource/panels/TagEntry.tsx +5 -4
  61. package/src/components/resource/panels/TaggingPanel.tsx +10 -16
  62. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +3 -2
  63. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +18 -52
  64. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +51 -20
  65. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +18 -56
  66. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +0 -1
  67. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +4 -5
  68. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +153 -0
  69. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +51 -106
  70. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +15 -47
  71. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +15 -47
  72. package/src/components/settings/SettingsPanel.tsx +8 -8
  73. package/src/components/settings/__tests__/SettingsPanel.test.tsx +12 -12
  74. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
  75. package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
  76. package/src/features/admin-exchange/components/ImportCard.tsx +2 -6
  77. package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
  78. package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
  79. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
  80. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
  81. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
  82. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
  83. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +5 -3
  84. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -22
  85. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +4 -3
  86. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
  87. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +38 -45
  88. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +123 -192
  89. package/dist/KnowledgeBaseSessionContext-BNNunwzO.d.mts +0 -175
  90. package/dist/PdfAnnotationCanvas.client-CHDCGQBR.mjs.map +0 -1
  91. package/dist/chunk-OZICDVH7.mjs +0 -62
  92. package/dist/chunk-OZICDVH7.mjs.map +0 -1
  93. package/dist/chunk-R4CCMFJH.mjs +0 -877
  94. package/dist/chunk-R4CCMFJH.mjs.map +0 -1
  95. package/dist/chunk-VN5NY4SN.mjs +0 -200
  96. package/dist/chunk-VN5NY4SN.mjs.map +0 -1
  97. package/src/components/modals/ProposeEntitiesModal.tsx +0 -179
  98. package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +0 -129
  99. package/src/features/resource-viewer/__tests__/AnnotationCreationPending.test.tsx +0 -323
  100. package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +0 -245
  101. package/src/features/resource-viewer/__tests__/AnnotationProgressDismissal.test.tsx +0 -303
  102. package/src/features/resource-viewer/__tests__/BindFlowIntegration.test.tsx +0 -150
  103. package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +0 -243
  104. package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +0 -383
  105. package/src/features/resource-viewer/__tests__/ResourceMutations.test.tsx +0 -299
  106. package/src/features/resource-viewer/__tests__/ToastNotifications.test.tsx +0 -186
  107. package/src/features/resource-viewer/__tests__/YieldFlowIntegration.test.tsx +0 -429
  108. 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
- });