@semiont/react-ui 0.2.33-build.81 → 0.2.33-build.83
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/dist/{PdfAnnotationCanvas.client-RAJRPQLU.mjs → PdfAnnotationCanvas.client-FGV33CWN.mjs} +9 -14
- package/dist/PdfAnnotationCanvas.client-FGV33CWN.mjs.map +1 -0
- package/dist/chunk-FC6SGLLT.mjs +141 -0
- package/dist/chunk-FC6SGLLT.mjs.map +1 -0
- package/dist/chunk-XS27QKGP.mjs +55 -0
- package/dist/chunk-XS27QKGP.mjs.map +1 -0
- package/dist/{chunk-QB52Q7EQ.mjs → chunk-YPYLOBA2.mjs} +31 -81
- package/dist/chunk-YPYLOBA2.mjs.map +1 -0
- package/dist/index.css +16 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +70 -28
- package/dist/index.mjs +564 -621
- package/dist/index.mjs.map +1 -1
- package/dist/test-utils.mjs +5 -3
- package/dist/test-utils.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/CodeMirrorRenderer.tsx +8 -8
- package/src/components/annotation/AnnotateToolbar.tsx +4 -1
- package/src/components/image-annotation/AnnotationOverlay.tsx +6 -17
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +6 -17
- package/src/components/resource/BrowseView.tsx +8 -8
- package/src/components/resource/__tests__/BrowseView.test.tsx +20 -12
- package/src/components/resource/panels/AssessmentEntry.tsx +3 -6
- package/src/components/resource/panels/CommentEntry.tsx +3 -6
- package/src/components/resource/panels/HighlightEntry.tsx +3 -6
- package/src/components/resource/panels/ReferenceEntry.tsx +3 -6
- package/src/components/resource/panels/TagEntry.tsx +3 -6
- package/src/components/resource/panels/TaggingPanel.tsx +5 -0
- package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +42 -4
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +44 -0
- package/src/components/toolbar/Toolbar.css +20 -0
- package/src/features/resource-viewer/__tests__/AnnotationCreationPending.test.tsx +312 -0
- package/src/features/resource-viewer/__tests__/GenerationFlowIntegration.test.tsx +4 -8
- package/src/features/resource-viewer/__tests__/ResolutionFlowIntegration.test.tsx +266 -0
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +5 -3
- package/dist/PdfAnnotationCanvas.client-RAJRPQLU.mjs.map +0 -1
- package/dist/chunk-QB52Q7EQ.mjs.map +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test: pendingAnnotation cleared after annotation:create succeeds
|
|
3
|
+
*
|
|
4
|
+
* Bug: handleAnnotationCreate in useDetectionFlow called the API and emitted
|
|
5
|
+
* annotation:created, but never called setPendingAnnotation(null). The pending
|
|
6
|
+
* creation form (e.g. "Create Reference", "Save" assessment) remained visible
|
|
7
|
+
* after the user clicked the confirm button.
|
|
8
|
+
*
|
|
9
|
+
* Fix: setPendingAnnotation(null) added in handleAnnotationCreate on success,
|
|
10
|
+
* before emitting annotation:created.
|
|
11
|
+
*
|
|
12
|
+
* This test covers all four motivations that have a pending form:
|
|
13
|
+
* - linking (ReferencesPanel: "Create Reference" button)
|
|
14
|
+
* - assessing (AssessmentPanel: "Save" button)
|
|
15
|
+
* - commenting (CommentsPanel: "Save" button)
|
|
16
|
+
* - tagging (TaggingPanel: category selection)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React from 'react';
|
|
20
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
21
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
22
|
+
import { act } from 'react';
|
|
23
|
+
import { useDetectionFlow } from '../../../hooks/useDetectionFlow';
|
|
24
|
+
import { EventBusProvider, useEventBus, resetEventBusForTesting } from '../../../contexts/EventBusContext';
|
|
25
|
+
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
26
|
+
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
27
|
+
import { SemiontApiClient } from '@semiont/api-client';
|
|
28
|
+
import { resourceUri } from '@semiont/api-client';
|
|
29
|
+
import type { Emitter } from 'mitt';
|
|
30
|
+
import type { EventMap } from '../../../contexts/EventBusContext';
|
|
31
|
+
import type { Motivation, Selector } from '@semiont/api-client';
|
|
32
|
+
|
|
33
|
+
const TEST_URI = resourceUri('http://localhost:4000/resources/test-resource');
|
|
34
|
+
|
|
35
|
+
const MOCK_ANNOTATION = {
|
|
36
|
+
id: 'http://localhost:4000/annotations/new-1',
|
|
37
|
+
type: 'Annotation',
|
|
38
|
+
motivation: 'linking' as Motivation,
|
|
39
|
+
target: { source: TEST_URI },
|
|
40
|
+
body: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const TEXT_SELECTOR: Selector = {
|
|
44
|
+
type: 'TextQuoteSelector',
|
|
45
|
+
exact: 'some selected text',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const SVG_SELECTOR: Selector = {
|
|
49
|
+
type: 'SvgSelector',
|
|
50
|
+
value: '<rect x="10" y="20" width="100" height="50"/>',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ─── Helper ───────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function renderDetectionFlow(testUri: string) {
|
|
56
|
+
let eventBusInstance: Emitter<EventMap>;
|
|
57
|
+
|
|
58
|
+
function EventBusCapture() {
|
|
59
|
+
eventBusInstance = useEventBus();
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function DetectionFlowHarness() {
|
|
64
|
+
const { pendingAnnotation } = useDetectionFlow(testUri as any);
|
|
65
|
+
return (
|
|
66
|
+
<div>
|
|
67
|
+
<div data-testid="pending-motivation">
|
|
68
|
+
{pendingAnnotation ? pendingAnnotation.motivation : 'none'}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
render(
|
|
75
|
+
<EventBusProvider>
|
|
76
|
+
<AuthTokenProvider token={null}>
|
|
77
|
+
<ApiClientProvider baseUrl="http://localhost:4000">
|
|
78
|
+
<EventBusCapture />
|
|
79
|
+
<DetectionFlowHarness />
|
|
80
|
+
</ApiClientProvider>
|
|
81
|
+
</AuthTokenProvider>
|
|
82
|
+
</EventBusProvider>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
getEventBus: () => eventBusInstance,
|
|
87
|
+
emit: <K extends keyof EventMap>(event: K, payload: EventMap[K]) => {
|
|
88
|
+
eventBusInstance.emit(event, payload);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('Annotation creation clears pendingAnnotation', () => {
|
|
96
|
+
let createAnnotationSpy: ReturnType<typeof vi.spyOn>;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
vi.clearAllMocks();
|
|
100
|
+
resetEventBusForTesting();
|
|
101
|
+
createAnnotationSpy = vi
|
|
102
|
+
.spyOn(SemiontApiClient.prototype, 'createAnnotation')
|
|
103
|
+
.mockResolvedValue({ annotation: MOCK_ANNOTATION } as any);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(() => {
|
|
107
|
+
vi.restoreAllMocks();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('clears pendingAnnotation after creating a reference (linking)', async () => {
|
|
111
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
112
|
+
|
|
113
|
+
// Set a pending annotation
|
|
114
|
+
act(() => {
|
|
115
|
+
emit('annotation:requested', { selector: TEXT_SELECTOR, motivation: 'linking' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('linking');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Emit annotation:create (what ReferencesPanel does when user clicks "Create Reference")
|
|
123
|
+
await act(async () => {
|
|
124
|
+
emit('annotation:create', {
|
|
125
|
+
motivation: 'linking',
|
|
126
|
+
selector: TEXT_SELECTOR,
|
|
127
|
+
body: [{ type: 'TextualBody', value: 'Person', purpose: 'tagging' }],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// pendingAnnotation must be cleared
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('none');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(createAnnotationSpy).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('clears pendingAnnotation after creating an assessment (assessing)', async () => {
|
|
140
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
emit('annotation:requested', { selector: SVG_SELECTOR, motivation: 'assessing' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('assessing');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await act(async () => {
|
|
151
|
+
emit('annotation:create', {
|
|
152
|
+
motivation: 'assessing',
|
|
153
|
+
selector: SVG_SELECTOR,
|
|
154
|
+
body: [{ type: 'TextualBody', value: 'Looks good', purpose: 'assessing' }],
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('none');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('clears pendingAnnotation after creating an assessment with empty body (optional text)', async () => {
|
|
164
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
165
|
+
|
|
166
|
+
act(() => {
|
|
167
|
+
emit('annotation:requested', { selector: SVG_SELECTOR, motivation: 'assessing' });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('assessing');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Empty body is valid for assessments
|
|
175
|
+
await act(async () => {
|
|
176
|
+
emit('annotation:create', {
|
|
177
|
+
motivation: 'assessing',
|
|
178
|
+
selector: SVG_SELECTOR,
|
|
179
|
+
body: [],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('none');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('clears pendingAnnotation after creating a comment (commenting)', async () => {
|
|
189
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
190
|
+
|
|
191
|
+
act(() => {
|
|
192
|
+
emit('annotation:requested', { selector: TEXT_SELECTOR, motivation: 'commenting' });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('commenting');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await act(async () => {
|
|
200
|
+
emit('annotation:create', {
|
|
201
|
+
motivation: 'commenting',
|
|
202
|
+
selector: TEXT_SELECTOR,
|
|
203
|
+
body: [{ type: 'TextualBody', value: 'Great point', purpose: 'commenting' }],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await waitFor(() => {
|
|
208
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('none');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('clears pendingAnnotation after creating a tag (tagging)', async () => {
|
|
213
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
214
|
+
|
|
215
|
+
act(() => {
|
|
216
|
+
emit('annotation:requested', { selector: SVG_SELECTOR, motivation: 'tagging' });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await waitFor(() => {
|
|
220
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('tagging');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await act(async () => {
|
|
224
|
+
emit('annotation:create', {
|
|
225
|
+
motivation: 'tagging',
|
|
226
|
+
selector: SVG_SELECTOR,
|
|
227
|
+
body: [{ type: 'TextualBody', value: 'concept:trust', purpose: 'tagging' }],
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await waitFor(() => {
|
|
232
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('none');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('emits annotation:created after successful creation', async () => {
|
|
237
|
+
const { emit, getEventBus } = renderDetectionFlow(TEST_URI);
|
|
238
|
+
|
|
239
|
+
const createdListener = vi.fn();
|
|
240
|
+
// Set listener after first render so eventBus is captured
|
|
241
|
+
await waitFor(() => expect(getEventBus()).toBeDefined());
|
|
242
|
+
getEventBus().on('annotation:created', createdListener);
|
|
243
|
+
|
|
244
|
+
act(() => {
|
|
245
|
+
emit('annotation:requested', { selector: TEXT_SELECTOR, motivation: 'linking' });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await act(async () => {
|
|
249
|
+
emit('annotation:create', {
|
|
250
|
+
motivation: 'linking',
|
|
251
|
+
selector: TEXT_SELECTOR,
|
|
252
|
+
body: [],
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
expect(createdListener).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(createdListener).toHaveBeenCalledWith({ annotation: MOCK_ANNOTATION });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('does NOT clear pendingAnnotation if API call fails', async () => {
|
|
263
|
+
createAnnotationSpy.mockRejectedValueOnce(new Error('Network error'));
|
|
264
|
+
|
|
265
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
266
|
+
|
|
267
|
+
act(() => {
|
|
268
|
+
emit('annotation:requested', { selector: TEXT_SELECTOR, motivation: 'linking' });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('linking');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await act(async () => {
|
|
276
|
+
emit('annotation:create', {
|
|
277
|
+
motivation: 'linking',
|
|
278
|
+
selector: TEXT_SELECTOR,
|
|
279
|
+
body: [],
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Give async rejection time to settle
|
|
284
|
+
await waitFor(() => {
|
|
285
|
+
// pending should remain — user can retry or cancel
|
|
286
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('linking');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('clears pendingAnnotation on cancel (annotation:cancel-pending)', async () => {
|
|
291
|
+
const { emit } = renderDetectionFlow(TEST_URI);
|
|
292
|
+
|
|
293
|
+
act(() => {
|
|
294
|
+
emit('annotation:requested', { selector: TEXT_SELECTOR, motivation: 'assessing' });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await waitFor(() => {
|
|
298
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('assessing');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
act(() => {
|
|
302
|
+
emit('annotation:cancel-pending', undefined);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
expect(screen.getByTestId('pending-motivation')).toHaveTextContent('none');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// API should NOT have been called on cancel
|
|
310
|
+
expect(createAnnotationSpy).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -23,11 +23,11 @@ import { render, screen, waitFor } from '@testing-library/react';
|
|
|
23
23
|
import { act } from 'react';
|
|
24
24
|
import { useGenerationFlow } from '../../../hooks/useGenerationFlow';
|
|
25
25
|
import { EventBusProvider, useEventBus, resetEventBusForTesting } from '../../../contexts/EventBusContext';
|
|
26
|
-
import { ApiClientProvider
|
|
26
|
+
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
27
27
|
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
28
|
-
import { useResolutionFlow } from '../../../
|
|
28
|
+
import { useResolutionFlow } from '../../../hooks/useResolutionFlow';
|
|
29
29
|
import { SSEClient } from '@semiont/api-client';
|
|
30
|
-
import type {
|
|
30
|
+
import type { ResourceUri, AnnotationUri } from '@semiont/api-client';
|
|
31
31
|
import { resourceUri, annotationUri } from '@semiont/api-client';
|
|
32
32
|
import type { Emitter } from 'mitt';
|
|
33
33
|
import type { EventMap } from '../../../contexts/EventBusContext';
|
|
@@ -415,13 +415,9 @@ function renderGenerationFlow(
|
|
|
415
415
|
// Component to capture EventBus instance and set up event operations
|
|
416
416
|
function EventBusCapture() {
|
|
417
417
|
eventBusInstance = useEventBus();
|
|
418
|
-
const client = useApiClient();
|
|
419
418
|
|
|
420
419
|
// Set up resolution flow (annotation:update-body, reference:link)
|
|
421
|
-
useResolutionFlow(
|
|
422
|
-
client: client as SemiontApiClient,
|
|
423
|
-
resourceUri: testResourceUri,
|
|
424
|
-
});
|
|
420
|
+
useResolutionFlow(testResourceUri);
|
|
425
421
|
|
|
426
422
|
return null;
|
|
427
423
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3: Feature Integration Test - Resolution Flow (search modal & body update)
|
|
3
|
+
*
|
|
4
|
+
* Tests the UNCOVERED half of useResolutionFlow:
|
|
5
|
+
* - reference:link → emits resolution:search-requested
|
|
6
|
+
* - resolution:search-requested → opens search modal with pendingReferenceId
|
|
7
|
+
* - onCloseSearchModal → closes modal
|
|
8
|
+
* - annotation:update-body → calls updateAnnotationBody API
|
|
9
|
+
* - annotation:update-body → emits annotation:body-updated on success
|
|
10
|
+
* - annotation:update-body → emits annotation:body-update-failed on error
|
|
11
|
+
* - auth token passed to updateAnnotationBody
|
|
12
|
+
*
|
|
13
|
+
* The deletion half of useResolutionFlow is covered by AnnotationDeletionIntegration.test.tsx.
|
|
14
|
+
*
|
|
15
|
+
* Uses real providers (EventBus, ApiClient, AuthToken) with mocked API boundary.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
19
|
+
import { render, waitFor } from '@testing-library/react';
|
|
20
|
+
import { act } from 'react';
|
|
21
|
+
import { useResolutionFlow } from '../../../hooks/useResolutionFlow';
|
|
22
|
+
import { EventBusProvider, useEventBus, resetEventBusForTesting } from '../../../contexts/EventBusContext';
|
|
23
|
+
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
24
|
+
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
25
|
+
import { SemiontApiClient, resourceUri, accessToken } from '@semiont/api-client';
|
|
26
|
+
|
|
27
|
+
describe('Resolution Flow - Search Modal & Body Update Integration', () => {
|
|
28
|
+
let updateAnnotationBodySpy: ReturnType<typeof vi.fn>;
|
|
29
|
+
const testUri = resourceUri('http://localhost:4000/resources/test-resource');
|
|
30
|
+
const testToken = 'test-resolution-token';
|
|
31
|
+
const testBaseUrl = 'http://localhost:4000';
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
resetEventBusForTesting();
|
|
36
|
+
|
|
37
|
+
updateAnnotationBodySpy = vi.fn().mockResolvedValue({ success: true });
|
|
38
|
+
vi.spyOn(SemiontApiClient.prototype, 'updateAnnotationBody').mockImplementation(updateAnnotationBodySpy);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── Render helper ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function renderResolutionFlow() {
|
|
48
|
+
let eventBusInstance: ReturnType<typeof useEventBus> | null = null;
|
|
49
|
+
let lastState: ReturnType<typeof useResolutionFlow> | null = null;
|
|
50
|
+
|
|
51
|
+
function TestComponent() {
|
|
52
|
+
eventBusInstance = useEventBus();
|
|
53
|
+
lastState = useResolutionFlow(testUri);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(
|
|
58
|
+
<AuthTokenProvider token={testToken}>
|
|
59
|
+
<EventBusProvider>
|
|
60
|
+
<ApiClientProvider baseUrl={testBaseUrl}>
|
|
61
|
+
<TestComponent />
|
|
62
|
+
</ApiClientProvider>
|
|
63
|
+
</EventBusProvider>
|
|
64
|
+
</AuthTokenProvider>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
getState: () => lastState!,
|
|
69
|
+
emit: (event: Parameters<typeof eventBusInstance.emit>[0], payload: Parameters<typeof eventBusInstance.emit>[1]) => {
|
|
70
|
+
act(() => { eventBusInstance!.emit(event as any, payload as any); });
|
|
71
|
+
},
|
|
72
|
+
on: (event: Parameters<typeof eventBusInstance.on>[0], handler: (payload: any) => void) => {
|
|
73
|
+
eventBusInstance!.on(event as any, handler);
|
|
74
|
+
},
|
|
75
|
+
off: (event: Parameters<typeof eventBusInstance.off>[0], handler: (payload: any) => void) => {
|
|
76
|
+
eventBusInstance!.off(event as any, handler);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Initial state ──────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
it('starts with search modal closed and no pending reference', () => {
|
|
84
|
+
const { getState } = renderResolutionFlow();
|
|
85
|
+
expect(getState().searchModalOpen).toBe(false);
|
|
86
|
+
expect(getState().pendingReferenceId).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── reference:link ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
it('reference:link emits resolution:search-requested with referenceId and searchTerm', () => {
|
|
92
|
+
const { emit, on, off } = renderResolutionFlow();
|
|
93
|
+
const searchRequestedSpy = vi.fn();
|
|
94
|
+
|
|
95
|
+
on('resolution:search-requested', searchRequestedSpy);
|
|
96
|
+
emit('reference:link', { annotationUri: 'ann-uri-123', searchTerm: 'climate change' });
|
|
97
|
+
off('resolution:search-requested', searchRequestedSpy);
|
|
98
|
+
|
|
99
|
+
expect(searchRequestedSpy).toHaveBeenCalledTimes(1);
|
|
100
|
+
expect(searchRequestedSpy).toHaveBeenCalledWith({
|
|
101
|
+
referenceId: 'ann-uri-123',
|
|
102
|
+
searchTerm: 'climate change',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── resolution:search-requested ────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
it('resolution:search-requested opens the search modal', async () => {
|
|
109
|
+
const { getState, emit } = renderResolutionFlow();
|
|
110
|
+
|
|
111
|
+
expect(getState().searchModalOpen).toBe(false);
|
|
112
|
+
|
|
113
|
+
emit('resolution:search-requested', { referenceId: 'ref-abc', searchTerm: 'oceans' });
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(getState().searchModalOpen).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolution:search-requested sets pendingReferenceId', async () => {
|
|
121
|
+
const { getState, emit } = renderResolutionFlow();
|
|
122
|
+
|
|
123
|
+
emit('resolution:search-requested', { referenceId: 'ref-xyz', searchTerm: 'forests' });
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(getState().pendingReferenceId).toBe('ref-xyz');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reference:link → resolution:search-requested chain opens modal end-to-end', async () => {
|
|
131
|
+
const { getState, emit } = renderResolutionFlow();
|
|
132
|
+
|
|
133
|
+
// Simulate the full user journey: user clicks "Link Document" on a reference entry
|
|
134
|
+
emit('reference:link', { annotationUri: 'ann-full-chain', searchTerm: 'biodiversity' });
|
|
135
|
+
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
expect(getState().searchModalOpen).toBe(true);
|
|
138
|
+
expect(getState().pendingReferenceId).toBe('ann-full-chain');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ─── onCloseSearchModal ──────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
it('onCloseSearchModal closes the search modal', async () => {
|
|
145
|
+
const { getState, emit } = renderResolutionFlow();
|
|
146
|
+
|
|
147
|
+
emit('resolution:search-requested', { referenceId: 'ref-close', searchTerm: 'test' });
|
|
148
|
+
|
|
149
|
+
await waitFor(() => expect(getState().searchModalOpen).toBe(true));
|
|
150
|
+
|
|
151
|
+
act(() => { getState().onCloseSearchModal(); });
|
|
152
|
+
|
|
153
|
+
await waitFor(() => {
|
|
154
|
+
expect(getState().searchModalOpen).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('onCloseSearchModal does not clear pendingReferenceId (preserves for re-open)', async () => {
|
|
159
|
+
const { getState, emit } = renderResolutionFlow();
|
|
160
|
+
|
|
161
|
+
emit('resolution:search-requested', { referenceId: 'ref-persist', searchTerm: 'test' });
|
|
162
|
+
await waitFor(() => expect(getState().searchModalOpen).toBe(true));
|
|
163
|
+
|
|
164
|
+
act(() => { getState().onCloseSearchModal(); });
|
|
165
|
+
await waitFor(() => expect(getState().searchModalOpen).toBe(false));
|
|
166
|
+
|
|
167
|
+
// pendingReferenceId remains — modal may reopen
|
|
168
|
+
expect(getState().pendingReferenceId).toBe('ref-persist');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ─── annotation:update-body ──────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
it('annotation:update-body calls updateAnnotationBody API', async () => {
|
|
174
|
+
const { emit } = renderResolutionFlow();
|
|
175
|
+
|
|
176
|
+
emit('annotation:update-body', {
|
|
177
|
+
annotationUri: 'http://localhost:4000/resources/test-resource/annotations/ann-body-1',
|
|
178
|
+
resourceId: 'linked-resource-id',
|
|
179
|
+
operations: [{ op: 'add', item: { id: 'linked-resource-id' } }],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(updateAnnotationBodySpy).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('annotation:update-body passes auth token to API call', async () => {
|
|
188
|
+
const { emit } = renderResolutionFlow();
|
|
189
|
+
|
|
190
|
+
emit('annotation:update-body', {
|
|
191
|
+
annotationUri: 'http://localhost:4000/resources/test-resource/annotations/ann-auth',
|
|
192
|
+
resourceId: 'resource-id',
|
|
193
|
+
operations: [{ op: 'replace', newItem: { id: 'resource-id' } }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(updateAnnotationBodySpy).toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const callArgs = updateAnnotationBodySpy.mock.calls[0];
|
|
201
|
+
expect(callArgs[2]).toHaveProperty('auth');
|
|
202
|
+
expect(callArgs[2].auth).toBe(accessToken(testToken));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('annotation:update-body emits annotation:body-updated on success', async () => {
|
|
206
|
+
const { emit, on, off } = renderResolutionFlow();
|
|
207
|
+
const bodyUpdatedSpy = vi.fn();
|
|
208
|
+
|
|
209
|
+
on('annotation:body-updated', bodyUpdatedSpy);
|
|
210
|
+
|
|
211
|
+
emit('annotation:update-body', {
|
|
212
|
+
annotationUri: 'http://localhost:4000/resources/test-resource/annotations/ann-success',
|
|
213
|
+
resourceId: 'resource-id',
|
|
214
|
+
operations: [{ op: 'add', item: { id: 'resource-id' } }],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(bodyUpdatedSpy).toHaveBeenCalledTimes(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
off('annotation:body-updated', bodyUpdatedSpy);
|
|
222
|
+
|
|
223
|
+
expect(bodyUpdatedSpy).toHaveBeenCalledWith({
|
|
224
|
+
annotationUri: 'http://localhost:4000/resources/test-resource/annotations/ann-success',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('annotation:update-body emits annotation:body-update-failed on API error', async () => {
|
|
229
|
+
updateAnnotationBodySpy.mockRejectedValue(new Error('Update failed'));
|
|
230
|
+
|
|
231
|
+
const { emit, on, off } = renderResolutionFlow();
|
|
232
|
+
const bodyUpdateFailedSpy = vi.fn();
|
|
233
|
+
|
|
234
|
+
on('annotation:body-update-failed', bodyUpdateFailedSpy);
|
|
235
|
+
|
|
236
|
+
emit('annotation:update-body', {
|
|
237
|
+
annotationUri: 'http://localhost:4000/resources/test-resource/annotations/ann-fail',
|
|
238
|
+
resourceId: 'resource-id',
|
|
239
|
+
operations: [{ op: 'remove', item: { id: 'old-id' } }],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await waitFor(() => {
|
|
243
|
+
expect(bodyUpdateFailedSpy).toHaveBeenCalledTimes(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
off('annotation:body-update-failed', bodyUpdateFailedSpy);
|
|
247
|
+
|
|
248
|
+
expect(bodyUpdateFailedSpy).toHaveBeenCalledWith({
|
|
249
|
+
error: expect.any(Error),
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('annotation:update-body called ONCE — no duplicate subscriptions', async () => {
|
|
254
|
+
const { emit } = renderResolutionFlow();
|
|
255
|
+
|
|
256
|
+
emit('annotation:update-body', {
|
|
257
|
+
annotationUri: 'http://localhost:4000/resources/test-resource/annotations/ann-dedup',
|
|
258
|
+
resourceId: 'resource-id',
|
|
259
|
+
operations: [{ op: 'add', item: { id: 'resource-id' } }],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await waitFor(() => {
|
|
263
|
+
expect(updateAnnotationBodySpy).toHaveBeenCalledTimes(1);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -35,8 +35,9 @@ import { useEventBus } from '../../../contexts/EventBusContext';
|
|
|
35
35
|
import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
|
|
36
36
|
import { useResourceAnnotations } from '../../../contexts/ResourceAnnotationsContext';
|
|
37
37
|
import { useApiClient } from '../../../contexts/ApiClientContext';
|
|
38
|
-
import { useResolutionFlow } from '../../../
|
|
38
|
+
import { useResolutionFlow } from '../../../hooks/useResolutionFlow';
|
|
39
39
|
import { useDetectionFlow } from '../../../hooks/useDetectionFlow';
|
|
40
|
+
import { useAttentionFlow } from '../../../hooks/useAttentionFlow';
|
|
40
41
|
import { usePanelNavigation } from '../../../hooks/usePanelNavigation';
|
|
41
42
|
import { useGenerationFlow } from '../../../hooks/useGenerationFlow';
|
|
42
43
|
import { useContextRetrievalFlow } from '../../../hooks/useContextRetrievalFlow';
|
|
@@ -159,9 +160,10 @@ export function ResourceViewerPage({
|
|
|
159
160
|
const allEntityTypes = (entityTypesData as { entityTypes: string[] } | undefined)?.entityTypes || [];
|
|
160
161
|
|
|
161
162
|
// Flow state hooks (NO CONTAINERS)
|
|
162
|
-
const {
|
|
163
|
+
const { hoveredAnnotationId } = useAttentionFlow();
|
|
164
|
+
const { detectingMotivation, detectionProgress, pendingAnnotation } = useDetectionFlow(rUri);
|
|
163
165
|
const { activePanel, scrollToAnnotationId, panelInitialTab, onScrollCompleted } = usePanelNavigation();
|
|
164
|
-
const { searchModalOpen, pendingReferenceId, onCloseSearchModal } = useResolutionFlow(
|
|
166
|
+
const { searchModalOpen, pendingReferenceId, onCloseSearchModal } = useResolutionFlow(rUri);
|
|
165
167
|
const {
|
|
166
168
|
generationProgress,
|
|
167
169
|
generationModalOpen,
|