@semiont/react-ui 0.2.33-build.80 → 0.2.33-build.81
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/{EventBusContext-7GvDyO0d.d.mts → EventBusContext-CJjL_cCf.d.mts} +67 -19
- package/dist/{chunk-ZR4ZV2LY.mjs → chunk-QB52Q7EQ.mjs} +7 -7
- package/dist/chunk-QB52Q7EQ.mjs.map +1 -0
- package/dist/index.d.mts +166 -199
- package/dist/index.mjs +1486 -1599
- package/dist/index.mjs.map +1 -1
- package/dist/test-utils.d.mts +2 -2
- package/dist/test-utils.mjs +1 -1
- package/package.json +1 -1
- package/src/components/LiveRegion.tsx +18 -18
- package/src/components/SessionExpiryBanner.tsx +2 -3
- package/src/components/SessionTimer.tsx +2 -2
- package/src/components/__tests__/SessionTimer.test.tsx +27 -27
- package/src/components/resource/panels/AssessmentPanel.tsx +2 -2
- package/src/components/resource/panels/DetectSection.tsx +13 -7
- package/src/components/resource/panels/ReferenceEntry.tsx +1 -1
- package/src/components/resource/panels/TaggingPanel.tsx +2 -3
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -1
- package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +16 -13
- package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +1 -1
- package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +13 -13
- package/src/features/resource-viewer/__tests__/DetectionProgressDismissal.test.tsx +5 -5
- package/src/features/resource-viewer/__tests__/GenerationFlowIntegration.test.tsx +5 -6
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +11 -12
- package/src/features/resource-viewer/__tests__/detection-progress-flow.test.tsx +3 -3
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +130 -93
- package/dist/chunk-ZR4ZV2LY.mjs.map +0 -1
package/dist/test-utils.d.mts
CHANGED
|
@@ -2,8 +2,8 @@ import { ReactElement } from 'react';
|
|
|
2
2
|
import { RenderOptions, RenderResult } from '@testing-library/react';
|
|
3
3
|
export * from '@testing-library/react';
|
|
4
4
|
import { QueryClient } from '@tanstack/react-query';
|
|
5
|
-
import { T as TranslationManager, S as SessionManager, O as OpenResourcesManager, E as EventBus } from './EventBusContext-
|
|
6
|
-
export { r as resetEventBusForTesting } from './EventBusContext-
|
|
5
|
+
import { T as TranslationManager, S as SessionManager, O as OpenResourcesManager, E as EventBus } from './EventBusContext-CJjL_cCf.mjs';
|
|
6
|
+
export { r as resetEventBusForTesting } from './EventBusContext-CJjL_cCf.mjs';
|
|
7
7
|
export { vi } from 'vitest';
|
|
8
8
|
import 'react/jsx-runtime';
|
|
9
9
|
import 'mitt';
|
package/dist/test-utils.mjs
CHANGED
package/package.json
CHANGED
|
@@ -76,11 +76,11 @@ export function useSearchAnnouncements() {
|
|
|
76
76
|
} else {
|
|
77
77
|
announce(`${count} result${count === 1 ? '' : 's'} found for ${query}`, 'polite');
|
|
78
78
|
}
|
|
79
|
-
}, []);
|
|
79
|
+
}, [announce]);
|
|
80
80
|
|
|
81
81
|
const announceSearching = useCallback(() => {
|
|
82
82
|
announce('Searching...', 'polite');
|
|
83
|
-
}, []);
|
|
83
|
+
}, [announce]);
|
|
84
84
|
|
|
85
85
|
return {
|
|
86
86
|
announceSearchResults,
|
|
@@ -93,31 +93,31 @@ export function useDocumentAnnouncements(annotators?: Record<string, Annotator>)
|
|
|
93
93
|
|
|
94
94
|
const announceDocumentSaved = useCallback(() => {
|
|
95
95
|
announce('Document saved successfully', 'polite');
|
|
96
|
-
}, []);
|
|
96
|
+
}, [announce]);
|
|
97
97
|
|
|
98
98
|
const announceDocumentDeleted = useCallback(() => {
|
|
99
99
|
announce('Document deleted', 'polite');
|
|
100
|
-
}, []);
|
|
100
|
+
}, [announce]);
|
|
101
101
|
|
|
102
102
|
const announceAnnotationCreated = useCallback((annotation: Annotation) => {
|
|
103
103
|
const metadata = annotators ? Object.values(annotators).find(a => a.matchesAnnotation(annotation)) : null;
|
|
104
104
|
const message = metadata?.announceOnCreate ?? 'Annotation created';
|
|
105
105
|
announce(message, 'polite');
|
|
106
|
-
}, [annotators]);
|
|
106
|
+
}, [announce, annotators]);
|
|
107
107
|
|
|
108
108
|
const announceAnnotationDeleted = useCallback(() => {
|
|
109
109
|
announce('Annotation deleted', 'polite');
|
|
110
|
-
}, []);
|
|
110
|
+
}, [announce]);
|
|
111
111
|
|
|
112
112
|
const announceAnnotationUpdated = useCallback((annotation: Annotation) => {
|
|
113
113
|
const metadata = annotators ? Object.values(annotators).find(a => a.matchesAnnotation(annotation)) : null;
|
|
114
114
|
const message = `${metadata?.displayName ?? 'Annotation'} updated`;
|
|
115
115
|
announce(message, 'polite');
|
|
116
|
-
}, [annotators]);
|
|
116
|
+
}, [announce, annotators]);
|
|
117
117
|
|
|
118
118
|
const announceError = useCallback((message: string) => {
|
|
119
119
|
announce(`Error: ${message}`, 'assertive');
|
|
120
|
-
}, []);
|
|
120
|
+
}, [announce]);
|
|
121
121
|
|
|
122
122
|
return {
|
|
123
123
|
announceDocumentSaved,
|
|
@@ -138,22 +138,22 @@ export function useResourceLoadingAnnouncements() {
|
|
|
138
138
|
? `Loading ${resourceName}...`
|
|
139
139
|
: 'Loading resource...';
|
|
140
140
|
announce(message, 'polite');
|
|
141
|
-
}, []);
|
|
141
|
+
}, [announce]);
|
|
142
142
|
|
|
143
143
|
const announceResourceLoaded = useCallback((resourceName: string) => {
|
|
144
144
|
announce(`${resourceName} loaded successfully`, 'polite');
|
|
145
|
-
}, []);
|
|
145
|
+
}, [announce]);
|
|
146
146
|
|
|
147
147
|
const announceResourceLoadError = useCallback((resourceName?: string) => {
|
|
148
148
|
const message = resourceName
|
|
149
149
|
? `Failed to load ${resourceName}`
|
|
150
150
|
: 'Failed to load resource';
|
|
151
151
|
announce(message, 'assertive');
|
|
152
|
-
}, []);
|
|
152
|
+
}, [announce]);
|
|
153
153
|
|
|
154
154
|
const announceResourceUpdating = useCallback((resourceName: string) => {
|
|
155
155
|
announce(`Updating ${resourceName}...`, 'polite');
|
|
156
|
-
}, []);
|
|
156
|
+
}, [announce]);
|
|
157
157
|
|
|
158
158
|
return {
|
|
159
159
|
announceResourceLoading,
|
|
@@ -169,22 +169,22 @@ export function useFormAnnouncements() {
|
|
|
169
169
|
|
|
170
170
|
const announceFormSubmitting = useCallback(() => {
|
|
171
171
|
announce('Submitting form...', 'polite');
|
|
172
|
-
}, []);
|
|
172
|
+
}, [announce]);
|
|
173
173
|
|
|
174
174
|
const announceFormSuccess = useCallback((message?: string) => {
|
|
175
175
|
announce(message || 'Form submitted successfully', 'polite');
|
|
176
|
-
}, []);
|
|
176
|
+
}, [announce]);
|
|
177
177
|
|
|
178
178
|
const announceFormError = useCallback((message?: string) => {
|
|
179
179
|
announce(message || 'Form submission failed. Please check your entries and try again.', 'assertive');
|
|
180
|
-
}, []);
|
|
180
|
+
}, [announce]);
|
|
181
181
|
|
|
182
182
|
const announceFormValidationError = useCallback((fieldCount: number) => {
|
|
183
183
|
const message = fieldCount === 1
|
|
184
184
|
? 'There is 1 field with an error'
|
|
185
185
|
: `There are ${fieldCount} fields with errors`;
|
|
186
186
|
announce(message, 'assertive');
|
|
187
|
-
}, []);
|
|
187
|
+
}, [announce]);
|
|
188
188
|
|
|
189
189
|
return {
|
|
190
190
|
announceFormSubmitting,
|
|
@@ -200,11 +200,11 @@ export function useLanguageChangeAnnouncements() {
|
|
|
200
200
|
|
|
201
201
|
const announceLanguageChanging = useCallback((newLanguage: string) => {
|
|
202
202
|
announce(`Changing language to ${newLanguage}...`, 'polite');
|
|
203
|
-
}, []);
|
|
203
|
+
}, [announce]);
|
|
204
204
|
|
|
205
205
|
const announceLanguageChanged = useCallback((newLanguage: string) => {
|
|
206
206
|
announce(`Language changed to ${newLanguage}`, 'polite');
|
|
207
|
-
}, []);
|
|
207
|
+
}, [announce]);
|
|
208
208
|
|
|
209
209
|
return {
|
|
210
210
|
announceLanguageChanging,
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { useSessionExpiry } from '@semiont/react-ui';
|
|
5
|
-
import { useFormattedTime } from '@semiont/react-ui';
|
|
4
|
+
import { useSessionExpiry, formatTime } from '@semiont/react-ui';
|
|
6
5
|
|
|
7
6
|
export function SessionExpiryBanner() {
|
|
8
7
|
const { timeRemaining, isExpiringSoon } = useSessionExpiry();
|
|
9
8
|
const [dismissed, setDismissed] = useState(false);
|
|
10
|
-
const formattedTime =
|
|
9
|
+
const formattedTime = formatTime(timeRemaining);
|
|
11
10
|
|
|
12
11
|
// Don't show if:
|
|
13
12
|
// - Session is not expiring soon
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useSessionExpiry } from '../hooks/useSessionExpiry';
|
|
4
|
-
import {
|
|
4
|
+
import { formatTime } from '../lib/formatTime';
|
|
5
5
|
|
|
6
6
|
export function SessionTimer() {
|
|
7
7
|
const { timeRemaining } = useSessionExpiry();
|
|
8
|
-
const formattedTime =
|
|
8
|
+
const formattedTime = formatTime(timeRemaining);
|
|
9
9
|
|
|
10
10
|
if (!formattedTime) return null;
|
|
11
11
|
|
|
@@ -7,18 +7,18 @@ vi.mock('../../hooks/useSessionExpiry', () => ({
|
|
|
7
7
|
useSessionExpiry: vi.fn(),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
vi.mock('../../
|
|
11
|
-
|
|
10
|
+
vi.mock('../../lib/formatTime', () => ({
|
|
11
|
+
formatTime: vi.fn(),
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
14
|
import { useSessionExpiry } from '../../hooks/useSessionExpiry';
|
|
15
|
-
import {
|
|
15
|
+
import { formatTime } from '../../lib/formatTime';
|
|
16
16
|
|
|
17
17
|
describe('SessionTimer', () => {
|
|
18
18
|
describe('Rendering', () => {
|
|
19
19
|
it('should render formatted time when available', () => {
|
|
20
20
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 300000 } as any);
|
|
21
|
-
vi.mocked(
|
|
21
|
+
vi.mocked(formatTime).mockReturnValue('5:00');
|
|
22
22
|
|
|
23
23
|
render(<SessionTimer />);
|
|
24
24
|
|
|
@@ -28,7 +28,7 @@ describe('SessionTimer', () => {
|
|
|
28
28
|
|
|
29
29
|
it('should have correct class name', () => {
|
|
30
30
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 300000 } as any);
|
|
31
|
-
vi.mocked(
|
|
31
|
+
vi.mocked(formatTime).mockReturnValue('5:00');
|
|
32
32
|
|
|
33
33
|
const { container } = render(<SessionTimer />);
|
|
34
34
|
|
|
@@ -37,7 +37,7 @@ describe('SessionTimer', () => {
|
|
|
37
37
|
|
|
38
38
|
it('should display complete message with formatted time', () => {
|
|
39
39
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 120000 } as any);
|
|
40
|
-
vi.mocked(
|
|
40
|
+
vi.mocked(formatTime).mockReturnValue('2:00');
|
|
41
41
|
|
|
42
42
|
render(<SessionTimer />);
|
|
43
43
|
|
|
@@ -49,7 +49,7 @@ describe('SessionTimer', () => {
|
|
|
49
49
|
describe('Null Cases', () => {
|
|
50
50
|
it('should return null when formattedTime is null', () => {
|
|
51
51
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
|
|
52
|
-
vi.mocked(
|
|
52
|
+
vi.mocked(formatTime).mockReturnValue(null);
|
|
53
53
|
|
|
54
54
|
const { container } = render(<SessionTimer />);
|
|
55
55
|
|
|
@@ -58,7 +58,7 @@ describe('SessionTimer', () => {
|
|
|
58
58
|
|
|
59
59
|
it('should return null when formattedTime is undefined', () => {
|
|
60
60
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
|
|
61
|
-
vi.mocked(
|
|
61
|
+
vi.mocked(formatTime).mockReturnValue(undefined as any);
|
|
62
62
|
|
|
63
63
|
const { container } = render(<SessionTimer />);
|
|
64
64
|
|
|
@@ -67,7 +67,7 @@ describe('SessionTimer', () => {
|
|
|
67
67
|
|
|
68
68
|
it('should return null when formattedTime is empty string', () => {
|
|
69
69
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
|
|
70
|
-
vi.mocked(
|
|
70
|
+
vi.mocked(formatTime).mockReturnValue('');
|
|
71
71
|
|
|
72
72
|
const { container } = render(<SessionTimer />);
|
|
73
73
|
|
|
@@ -79,31 +79,31 @@ describe('SessionTimer', () => {
|
|
|
79
79
|
it('should call useSessionExpiry hook', () => {
|
|
80
80
|
const mockUseSessionExpiry = vi.mocked(useSessionExpiry);
|
|
81
81
|
mockUseSessionExpiry.mockReturnValue({ timeRemaining: 100000 } as any);
|
|
82
|
-
vi.mocked(
|
|
82
|
+
vi.mocked(formatTime).mockReturnValue('1:40');
|
|
83
83
|
|
|
84
84
|
render(<SessionTimer />);
|
|
85
85
|
|
|
86
86
|
expect(mockUseSessionExpiry).toHaveBeenCalled();
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it('should call
|
|
90
|
-
const
|
|
89
|
+
it('should call formatTime with timeRemaining', () => {
|
|
90
|
+
const mockFormatTime = vi.mocked(formatTime);
|
|
91
91
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 300000 } as any);
|
|
92
|
-
|
|
92
|
+
mockFormatTime.mockReturnValue('5:00');
|
|
93
93
|
|
|
94
94
|
render(<SessionTimer />);
|
|
95
95
|
|
|
96
|
-
expect(
|
|
96
|
+
expect(mockFormatTime).toHaveBeenCalledWith(300000);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it('should pass correct timeRemaining to
|
|
100
|
-
const
|
|
99
|
+
it('should pass correct timeRemaining to formatTime', () => {
|
|
100
|
+
const mockFormatTime = vi.mocked(formatTime);
|
|
101
101
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 12345 } as any);
|
|
102
|
-
|
|
102
|
+
mockFormatTime.mockReturnValue('0:12');
|
|
103
103
|
|
|
104
104
|
render(<SessionTimer />);
|
|
105
105
|
|
|
106
|
-
expect(
|
|
106
|
+
expect(mockFormatTime).toHaveBeenCalledWith(12345);
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
109
|
|
|
@@ -118,7 +118,7 @@ describe('SessionTimer', () => {
|
|
|
118
118
|
|
|
119
119
|
testCases.forEach(({ timeRemaining, formatted }) => {
|
|
120
120
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining } as any);
|
|
121
|
-
vi.mocked(
|
|
121
|
+
vi.mocked(formatTime).mockReturnValue(formatted);
|
|
122
122
|
|
|
123
123
|
const { unmount } = render(<SessionTimer />);
|
|
124
124
|
|
|
@@ -132,17 +132,17 @@ describe('SessionTimer', () => {
|
|
|
132
132
|
describe('Re-rendering', () => {
|
|
133
133
|
it('should update when timeRemaining changes', () => {
|
|
134
134
|
const mockUseSessionExpiry = vi.mocked(useSessionExpiry);
|
|
135
|
-
const
|
|
135
|
+
const mockFormatTime = vi.mocked(formatTime);
|
|
136
136
|
|
|
137
137
|
mockUseSessionExpiry.mockReturnValue({ timeRemaining: 60000 } as any);
|
|
138
|
-
|
|
138
|
+
mockFormatTime.mockReturnValue('1:00');
|
|
139
139
|
|
|
140
140
|
const { rerender } = render(<SessionTimer />);
|
|
141
141
|
expect(screen.getByText('Session: 1:00 remaining')).toBeInTheDocument();
|
|
142
142
|
|
|
143
143
|
// Simulate time passing
|
|
144
144
|
mockUseSessionExpiry.mockReturnValue({ timeRemaining: 30000 } as any);
|
|
145
|
-
|
|
145
|
+
mockFormatTime.mockReturnValue('0:30');
|
|
146
146
|
|
|
147
147
|
rerender(<SessionTimer />);
|
|
148
148
|
expect(screen.getByText('Session: 0:30 remaining')).toBeInTheDocument();
|
|
@@ -150,13 +150,13 @@ describe('SessionTimer', () => {
|
|
|
150
150
|
|
|
151
151
|
it('should hide when formattedTime becomes null', () => {
|
|
152
152
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 60000 } as any);
|
|
153
|
-
vi.mocked(
|
|
153
|
+
vi.mocked(formatTime).mockReturnValue('1:00');
|
|
154
154
|
|
|
155
155
|
const { container, rerender } = render(<SessionTimer />);
|
|
156
156
|
expect(screen.getByText('Session: 1:00 remaining')).toBeInTheDocument();
|
|
157
157
|
|
|
158
158
|
// Time expires
|
|
159
|
-
vi.mocked(
|
|
159
|
+
vi.mocked(formatTime).mockReturnValue(null);
|
|
160
160
|
|
|
161
161
|
rerender(<SessionTimer />);
|
|
162
162
|
expect(container.firstChild).toBeNull();
|
|
@@ -166,7 +166,7 @@ describe('SessionTimer', () => {
|
|
|
166
166
|
describe('Edge Cases', () => {
|
|
167
167
|
it('should handle zero timeRemaining', () => {
|
|
168
168
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 0 } as any);
|
|
169
|
-
vi.mocked(
|
|
169
|
+
vi.mocked(formatTime).mockReturnValue('0:00');
|
|
170
170
|
|
|
171
171
|
render(<SessionTimer />);
|
|
172
172
|
|
|
@@ -175,7 +175,7 @@ describe('SessionTimer', () => {
|
|
|
175
175
|
|
|
176
176
|
it('should handle negative timeRemaining', () => {
|
|
177
177
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: -1000 } as any);
|
|
178
|
-
vi.mocked(
|
|
178
|
+
vi.mocked(formatTime).mockReturnValue('expired');
|
|
179
179
|
|
|
180
180
|
render(<SessionTimer />);
|
|
181
181
|
|
|
@@ -184,7 +184,7 @@ describe('SessionTimer', () => {
|
|
|
184
184
|
|
|
185
185
|
it('should handle very large timeRemaining', () => {
|
|
186
186
|
vi.mocked(useSessionExpiry).mockReturnValue({ timeRemaining: 999999999 } as any);
|
|
187
|
-
vi.mocked(
|
|
187
|
+
vi.mocked(formatTime).mockReturnValue('16666:39');
|
|
188
188
|
|
|
189
189
|
render(<SessionTimer />);
|
|
190
190
|
|
|
@@ -137,8 +137,8 @@ export function AssessmentPanel({
|
|
|
137
137
|
|
|
138
138
|
const handleSaveNewAssessment = () => {
|
|
139
139
|
if (pendingAnnotation) {
|
|
140
|
-
const body = newAssessmentText.trim()
|
|
141
|
-
? [{ type: 'TextualBody', value: newAssessmentText, purpose: 'assessing' }]
|
|
140
|
+
const body: components['schemas']['AnnotationBody'][] = newAssessmentText.trim()
|
|
141
|
+
? [{ type: 'TextualBody' as const, value: newAssessmentText, purpose: 'assessing' as const }]
|
|
142
142
|
: [];
|
|
143
143
|
|
|
144
144
|
eventBus.emit('annotation:create', {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
5
5
|
import { useEventBus } from '../../../contexts/EventBusContext';
|
|
6
6
|
import type { Motivation } from '@semiont/api-client';
|
|
@@ -29,6 +29,7 @@ interface DetectSectionProps {
|
|
|
29
29
|
* - Progress display during detection
|
|
30
30
|
*
|
|
31
31
|
* @emits detection:start - Start detection for annotation type. Payload: { motivation: Motivation, options: { instructions?: string, tone?: string, density?: number } }
|
|
32
|
+
* @emits detection:dismiss-progress - Dismiss the detection progress display
|
|
32
33
|
*/
|
|
33
34
|
export function DetectSection({
|
|
34
35
|
annotationType,
|
|
@@ -42,7 +43,8 @@ export function DetectSection({
|
|
|
42
43
|
const t = useTranslations(panelName);
|
|
43
44
|
const eventBus = useEventBus();
|
|
44
45
|
const [instructions, setInstructions] = useState('');
|
|
45
|
-
|
|
46
|
+
type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
|
|
47
|
+
const [tone, setTone] = useState<ToneValue>('');
|
|
46
48
|
// Default density depends on annotation type
|
|
47
49
|
const defaultDensity = annotationType === 'comment' ? 5 : annotationType === 'assessment' ? 4 : annotationType === 'highlight' ? 5 : 5;
|
|
48
50
|
const [density, setDensity] = useState(defaultDensity);
|
|
@@ -61,7 +63,7 @@ export function DetectSection({
|
|
|
61
63
|
localStorage.setItem(`detect-section-expanded-${annotationType}`, String(isExpanded));
|
|
62
64
|
}, [isExpanded, annotationType]);
|
|
63
65
|
|
|
64
|
-
const handleDetect = () => {
|
|
66
|
+
const handleDetect = useCallback(() => {
|
|
65
67
|
// Map annotation type to motivation
|
|
66
68
|
const motivation: Motivation =
|
|
67
69
|
annotationType === 'highlight' ? 'highlighting' :
|
|
@@ -73,7 +75,7 @@ export function DetectSection({
|
|
|
73
75
|
motivation,
|
|
74
76
|
options: {
|
|
75
77
|
instructions: instructions.trim() || undefined,
|
|
76
|
-
tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone
|
|
78
|
+
tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone : undefined,
|
|
77
79
|
density: (annotationType === 'comment' || annotationType === 'assessment' || annotationType === 'highlight') && useDensity ? density : undefined,
|
|
78
80
|
},
|
|
79
81
|
});
|
|
@@ -81,7 +83,11 @@ export function DetectSection({
|
|
|
81
83
|
setInstructions('');
|
|
82
84
|
setTone('');
|
|
83
85
|
// Don't reset density/useDensity - persist across detections
|
|
84
|
-
};
|
|
86
|
+
}, [annotationType, instructions, tone, useDensity, density]); // eventBus is stable singleton - never in deps
|
|
87
|
+
|
|
88
|
+
const handleDismissProgress = useCallback(() => {
|
|
89
|
+
eventBus.emit('detection:dismiss-progress', undefined);
|
|
90
|
+
}, []); // eventBus is stable singleton - never in deps
|
|
85
91
|
|
|
86
92
|
return (
|
|
87
93
|
<div className="semiont-panel__section">
|
|
@@ -134,7 +140,7 @@ export function DetectSection({
|
|
|
134
140
|
</label>
|
|
135
141
|
<select
|
|
136
142
|
value={tone}
|
|
137
|
-
onChange={(e) => setTone(e.target.value)}
|
|
143
|
+
onChange={(e) => setTone(e.target.value as ToneValue)}
|
|
138
144
|
className="semiont-select"
|
|
139
145
|
>
|
|
140
146
|
<option value="">Default</option>
|
|
@@ -233,7 +239,7 @@ export function DetectSection({
|
|
|
233
239
|
{/* Close button - shown after detection completes (when not actively detecting) */}
|
|
234
240
|
{!isDetecting && (
|
|
235
241
|
<button
|
|
236
|
-
onClick={
|
|
242
|
+
onClick={handleDismissProgress}
|
|
237
243
|
className="semiont-detection-progress__close"
|
|
238
244
|
aria-label={t('closeProgress')}
|
|
239
245
|
title={t('closeProgress')}
|
|
@@ -87,7 +87,7 @@ export const ReferenceEntry = forwardRef<HTMLDivElement, ReferenceEntryProps>(
|
|
|
87
87
|
eventBus.emit('annotation:update-body', {
|
|
88
88
|
annotationUri: reference.id,
|
|
89
89
|
resourceId: sourceUri.split('/resources/')[1] || '',
|
|
90
|
-
operations: [{ op: 'remove'
|
|
90
|
+
operations: [{ op: 'remove' }], // Remove all body items
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
};
|
|
@@ -279,10 +279,9 @@ export function TaggingPanel({
|
|
|
279
279
|
selector: pendingAnnotation.selector,
|
|
280
280
|
body: [
|
|
281
281
|
{
|
|
282
|
-
type: 'TextualBody',
|
|
282
|
+
type: 'TextualBody' as const,
|
|
283
283
|
value: e.target.value,
|
|
284
|
-
purpose: 'tagging',
|
|
285
|
-
schema: selectedSchemaId,
|
|
284
|
+
purpose: 'tagging' as const,
|
|
286
285
|
},
|
|
287
286
|
],
|
|
288
287
|
});
|
|
@@ -381,7 +381,7 @@ describe('TaggingPanel Component', () => {
|
|
|
381
381
|
e.event === 'annotation:create' &&
|
|
382
382
|
e.payload?.motivation === 'tagging' &&
|
|
383
383
|
e.payload?.body?.[0]?.value === 'Issue' &&
|
|
384
|
-
e.payload?.body?.[0]?.
|
|
384
|
+
e.payload?.body?.[0]?.type === 'TextualBody'
|
|
385
385
|
)).toBe(true);
|
|
386
386
|
});
|
|
387
387
|
});
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Tests the COMPLETE annotation deletion flow with real component composition:
|
|
5
5
|
* - EventBusProvider (REAL)
|
|
6
6
|
* - ApiClientProvider (REAL, with MOCKED client)
|
|
7
|
-
* -
|
|
8
|
-
* - useEventOperations (REAL)
|
|
7
|
+
* - useDetectionFlow (REAL) — single registration point for useResolutionFlow
|
|
9
8
|
* - useEventSubscriptions (REAL)
|
|
10
9
|
*
|
|
11
10
|
* This test focuses on ARCHITECTURE and EVENT WIRING:
|
|
@@ -16,14 +15,18 @@
|
|
|
16
15
|
*
|
|
17
16
|
* CRITICAL: This test prevents regressions where:
|
|
18
17
|
* - Multiple deletion paths exist (event-driven vs direct)
|
|
19
|
-
* -
|
|
18
|
+
* - useResolutionFlow called in more than one hook (causes duplicate subscriptions)
|
|
20
19
|
* - Auth token missing from API calls (401 errors)
|
|
20
|
+
*
|
|
21
|
+
* ARCHITECTURE: useResolutionFlow is called ONLY in useDetectionFlow.
|
|
22
|
+
* useDetectionFlow handles all detection state (manual annotation selection
|
|
23
|
+
* and AI-driven SSE detection) plus all API operations via useResolutionFlow.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
24
27
|
import { render, waitFor } from '@testing-library/react';
|
|
25
28
|
import { act } from 'react';
|
|
26
|
-
import {
|
|
29
|
+
import { useDetectionFlow } from '../../../hooks/useDetectionFlow';
|
|
27
30
|
import { EventBusProvider, useEventBus, resetEventBusForTesting } from '../../../contexts/EventBusContext';
|
|
28
31
|
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
29
32
|
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
@@ -56,7 +59,9 @@ describe('Annotation Deletion - Feature Integration', () => {
|
|
|
56
59
|
|
|
57
60
|
function TestComponent() {
|
|
58
61
|
eventBusInstance = useEventBus();
|
|
59
|
-
|
|
62
|
+
// useDetectionFlow is the single registration point for useResolutionFlow
|
|
63
|
+
// (handles annotation:delete, annotation:create, detection:start, etc.)
|
|
64
|
+
useDetectionFlow(testUri);
|
|
60
65
|
return null;
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -186,16 +191,14 @@ describe('Annotation Deletion - Feature Integration', () => {
|
|
|
186
191
|
expect(deleteAnnotationSpy.mock.calls[1][0]).toContain('annotation-2');
|
|
187
192
|
});
|
|
188
193
|
|
|
189
|
-
it('ARCHITECTURE:
|
|
194
|
+
it('ARCHITECTURE: useResolutionFlow is called in useDetectionFlow (single registration point)', async () => {
|
|
190
195
|
/**
|
|
191
196
|
* This test validates that there's only ONE event-driven deletion path:
|
|
192
|
-
* -
|
|
193
|
-
* -
|
|
194
|
-
* - No other component/hook subscribes to annotation:delete
|
|
197
|
+
* - useDetectionFlow calls useResolutionFlow (the single registration point)
|
|
198
|
+
* - useResolutionFlow subscribes to annotation:delete
|
|
195
199
|
*
|
|
196
|
-
* If this test fails, it means
|
|
197
|
-
*
|
|
198
|
-
* 2. Duplicate subscription added elsewhere (ARCHITECTURE VIOLATION)
|
|
200
|
+
* If this test fails with 2 API calls, it means useResolutionFlow was added
|
|
201
|
+
* to a second hook, causing duplicate subscriptions (ARCHITECTURE VIOLATION).
|
|
199
202
|
*/
|
|
200
203
|
|
|
201
204
|
const { emitDelete } = renderAnnotationFlow();
|
|
@@ -216,7 +219,7 @@ describe('Annotation Deletion - Feature Integration', () => {
|
|
|
216
219
|
*
|
|
217
220
|
* The correct pattern is event-driven only:
|
|
218
221
|
* - UI emits annotation:delete event
|
|
219
|
-
* -
|
|
222
|
+
* - useResolutionFlow handles it
|
|
220
223
|
* - No direct function calls
|
|
221
224
|
*/
|
|
222
225
|
|
|
@@ -25,7 +25,7 @@ describe('REPRODUCING BUG: Detection state not updating', () => {
|
|
|
25
25
|
vi.clearAllMocks();
|
|
26
26
|
|
|
27
27
|
// Minimal mock - SSE streams not needed for this test
|
|
28
|
-
vi.spyOn(SSEClient.prototype, '
|
|
28
|
+
vi.spyOn(SSEClient.prototype, 'detectReferences').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
|
|
29
29
|
vi.spyOn(SSEClient.prototype, 'detectHighlights').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
|
|
30
30
|
vi.spyOn(SSEClient.prototype, 'detectComments').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
|
|
31
31
|
vi.spyOn(SSEClient.prototype, 'detectAssessments').mockReturnValue({ onProgress: vi.fn().mockReturnThis(), onComplete: vi.fn().mockReturnThis(), onError: vi.fn().mockReturnThis(), close: vi.fn() } as any);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - EventBusProvider (REAL)
|
|
6
6
|
* - ApiClientProvider (REAL, with MOCKED client)
|
|
7
7
|
* - useDetectionFlow (REAL)
|
|
8
|
-
* -
|
|
8
|
+
* - useResolutionFlow (REAL)
|
|
9
9
|
* - useEventSubscriptions (REAL)
|
|
10
10
|
*
|
|
11
11
|
* This test focuses on ARCHITECTURE and EVENT WIRING:
|
|
@@ -60,7 +60,7 @@ const createMockSSEStream = () => {
|
|
|
60
60
|
|
|
61
61
|
describe('Detection Flow - Feature Integration', () => {
|
|
62
62
|
let mockStream: ReturnType<typeof createMockSSEStream>;
|
|
63
|
-
let
|
|
63
|
+
let detectReferencesSpy: any;
|
|
64
64
|
let detectHighlightsSpy: any;
|
|
65
65
|
let detectCommentsSpy: any;
|
|
66
66
|
|
|
@@ -72,7 +72,7 @@ describe('Detection Flow - Feature Integration', () => {
|
|
|
72
72
|
mockStream = createMockSSEStream();
|
|
73
73
|
|
|
74
74
|
// Spy on SSEClient prototype methods
|
|
75
|
-
|
|
75
|
+
detectReferencesSpy = vi.spyOn(SSEClient.prototype, 'detectReferences').mockReturnValue(mockStream as any);
|
|
76
76
|
detectHighlightsSpy = vi.spyOn(SSEClient.prototype, 'detectHighlights').mockReturnValue(mockStream as any);
|
|
77
77
|
detectCommentsSpy = vi.spyOn(SSEClient.prototype, 'detectComments').mockReturnValue(mockStream as any);
|
|
78
78
|
vi.spyOn(SSEClient.prototype, 'detectAssessments').mockReturnValue(mockStream as any);
|
|
@@ -82,13 +82,13 @@ describe('Detection Flow - Feature Integration', () => {
|
|
|
82
82
|
vi.restoreAllMocks();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it('should call
|
|
85
|
+
it('should call detectReferences exactly ONCE when detection starts (not twice)', async () => {
|
|
86
86
|
const testUri = resourceUri('http://localhost:4000/resources/test-resource');
|
|
87
87
|
|
|
88
88
|
// Render with real component composition
|
|
89
89
|
const { emitDetectionStart } = renderDetectionFlow(testUri);
|
|
90
90
|
|
|
91
|
-
// Trigger detection for linking (uses
|
|
91
|
+
// Trigger detection for linking (uses detectReferences)
|
|
92
92
|
act(() => {
|
|
93
93
|
emitDetectionStart('linking', {
|
|
94
94
|
entityTypes: ['Person', 'Organization'],
|
|
@@ -97,13 +97,13 @@ describe('Detection Flow - Feature Integration', () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
// CRITICAL ASSERTION: API called exactly once (not twice!)
|
|
100
|
-
// This would FAIL if
|
|
100
|
+
// This would FAIL if useResolutionFlow was called in multiple places
|
|
101
101
|
await waitFor(() => {
|
|
102
|
-
expect(
|
|
102
|
+
expect(detectReferencesSpy).toHaveBeenCalledTimes(1);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
// Verify correct parameters
|
|
106
|
-
expect(
|
|
106
|
+
expect(detectReferencesSpy).toHaveBeenCalledWith(
|
|
107
107
|
testUri,
|
|
108
108
|
{
|
|
109
109
|
entityTypes: ['Person', 'Organization'],
|
|
@@ -128,7 +128,7 @@ describe('Detection Flow - Feature Integration', () => {
|
|
|
128
128
|
|
|
129
129
|
// Wait for stream to be created
|
|
130
130
|
await waitFor(() => {
|
|
131
|
-
expect(
|
|
131
|
+
expect(detectReferencesSpy).toHaveBeenCalled();
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
// Simulate SSE progress callback being invoked
|
|
@@ -316,8 +316,8 @@ describe('Detection Flow - Feature Integration', () => {
|
|
|
316
316
|
it('should only call API once even with multiple event listeners', async () => {
|
|
317
317
|
const testUri = resourceUri('http://localhost:4000/resources/test-resource');
|
|
318
318
|
|
|
319
|
-
// This test specifically catches the duplicate
|
|
320
|
-
// If multiple components call
|
|
319
|
+
// This test specifically catches the duplicate useResolutionFlow bug
|
|
320
|
+
// If multiple components call useResolutionFlow, we'll see multiple API calls
|
|
321
321
|
const { emitDetectionStart, getEventBus } = renderDetectionFlow(testUri);
|
|
322
322
|
|
|
323
323
|
// Add an additional event listener (simulating multiple subscribers)
|
|
@@ -331,11 +331,11 @@ describe('Detection Flow - Feature Integration', () => {
|
|
|
331
331
|
|
|
332
332
|
// Wait for operation to complete
|
|
333
333
|
await waitFor(() => {
|
|
334
|
-
expect(
|
|
334
|
+
expect(detectReferencesSpy).toHaveBeenCalled();
|
|
335
335
|
});
|
|
336
336
|
|
|
337
337
|
// VERIFY: API called exactly once, even though multiple listeners exist
|
|
338
|
-
expect(
|
|
338
|
+
expect(detectReferencesSpy).toHaveBeenCalledTimes(1);
|
|
339
339
|
|
|
340
340
|
// VERIFY: Our additional listener was called (events work)
|
|
341
341
|
expect(additionalListener).toHaveBeenCalledTimes(1);
|