@semiont/react-ui 0.2.36 → 0.2.37

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 (33) hide show
  1. package/dist/index.d.mts +8 -0
  2. package/dist/index.mjs +252 -166
  3. package/dist/index.mjs.map +1 -1
  4. package/package.json +1 -1
  5. package/src/components/CodeMirrorRenderer.tsx +71 -203
  6. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +142 -0
  7. package/src/components/__tests__/LiveRegion.hooks.test.tsx +79 -0
  8. package/src/components/__tests__/ResizeHandle.test.tsx +165 -0
  9. package/src/components/__tests__/SessionExpiryBanner.test.tsx +123 -0
  10. package/src/components/__tests__/StatusDisplay.test.tsx +160 -0
  11. package/src/components/__tests__/Toolbar.test.tsx +110 -0
  12. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +285 -0
  13. package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +273 -0
  14. package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +90 -0
  15. package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +129 -0
  16. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +180 -0
  17. package/src/components/navigation/__tests__/ObservableLink.test.tsx +90 -0
  18. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +169 -0
  19. package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +371 -0
  20. package/src/components/resource/AnnotateView.tsx +27 -153
  21. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
  22. package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
  23. package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
  24. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
  25. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
  26. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
  27. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
  28. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
  29. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
  30. package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
  31. package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
  32. package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
  33. package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ formatEventType,
4
+ getEventEmoji,
5
+ formatRelativeTime,
6
+ getEventDisplayContent,
7
+ getEventEntityTypes,
8
+ getResourceCreationDetails,
9
+ } from '../event-formatting';
10
+
11
+ // Mock api-client functions
12
+ vi.mock('@semiont/api-client', () => ({
13
+ getExactText: vi.fn((selector: any) => selector?.exact ?? null),
14
+ getTargetSelector: vi.fn((target: any) => target?.selector ?? null),
15
+ }));
16
+
17
+ const t = vi.fn((key: string, params?: Record<string, string | number>) => {
18
+ if (params) return `${key}(${JSON.stringify(params)})`;
19
+ return key;
20
+ });
21
+
22
+ describe('event-formatting', () => {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ });
26
+
27
+ describe('formatEventType', () => {
28
+ it('returns translation key for resource events', () => {
29
+ expect(formatEventType('resource.created', t)).toBe('resourceCreated');
30
+ expect(formatEventType('resource.cloned', t)).toBe('resourceCloned');
31
+ expect(formatEventType('resource.archived', t)).toBe('resourceArchived');
32
+ expect(formatEventType('resource.unarchived', t)).toBe('resourceUnarchived');
33
+ });
34
+
35
+ it('returns motivation-specific key for annotation.added', () => {
36
+ expect(formatEventType('annotation.added', t, { annotation: { motivation: 'highlighting' } })).toBe('highlightAdded');
37
+ expect(formatEventType('annotation.added', t, { annotation: { motivation: 'linking' } })).toBe('referenceCreated');
38
+ expect(formatEventType('annotation.added', t, { annotation: { motivation: 'assessing' } })).toBe('assessmentAdded');
39
+ expect(formatEventType('annotation.added', t, { annotation: { motivation: 'commenting' } })).toBe('annotationAdded');
40
+ });
41
+
42
+ it('returns annotationRemoved for annotation.removed', () => {
43
+ expect(formatEventType('annotation.removed', t)).toBe('annotationRemoved');
44
+ });
45
+
46
+ it('returns annotationBodyUpdated for annotation.body.updated', () => {
47
+ expect(formatEventType('annotation.body.updated', t)).toBe('annotationBodyUpdated');
48
+ });
49
+
50
+ it('returns entitytag keys', () => {
51
+ expect(formatEventType('entitytag.added', t)).toBe('entitytagAdded');
52
+ expect(formatEventType('entitytag.removed', t)).toBe('entitytagRemoved');
53
+ });
54
+
55
+ it('returns jobEvent for job types', () => {
56
+ expect(formatEventType('job.completed', t)).toBe('jobEvent');
57
+ expect(formatEventType('job.started', t)).toBe('jobEvent');
58
+ expect(formatEventType('job.failed', t)).toBe('jobEvent');
59
+ });
60
+
61
+ it('returns representationEvent for representation types', () => {
62
+ expect(formatEventType('representation.added', t)).toBe('representationEvent');
63
+ expect(formatEventType('representation.removed', t)).toBe('representationEvent');
64
+ });
65
+
66
+ it('returns raw type for unknown event types', () => {
67
+ expect(formatEventType('custom.event' as any, t)).toBe('custom.event');
68
+ });
69
+ });
70
+
71
+ describe('getEventEmoji', () => {
72
+ it('returns document emoji for resource events', () => {
73
+ expect(getEventEmoji('resource.created')).toBe('📄');
74
+ expect(getEventEmoji('resource.cloned')).toBe('📄');
75
+ });
76
+
77
+ it('returns motivation-specific emoji for annotation.added', () => {
78
+ expect(getEventEmoji('annotation.added', { annotation: { motivation: 'highlighting' } })).toBe('🟡');
79
+ expect(getEventEmoji('annotation.added', { annotation: { motivation: 'linking' } })).toBeTruthy();
80
+ expect(getEventEmoji('annotation.added', { annotation: { motivation: 'assessing' } })).toBe('🔴');
81
+ });
82
+
83
+ it('returns trash emoji for annotation.removed', () => {
84
+ expect(getEventEmoji('annotation.removed')).toBe('🗑️');
85
+ });
86
+
87
+ it('returns pencil emoji for annotation.body.updated', () => {
88
+ expect(getEventEmoji('annotation.body.updated')).toBe('✏️');
89
+ });
90
+
91
+ it('returns tag emoji for entitytag events', () => {
92
+ expect(getEventEmoji('entitytag.added')).toBe('🏷️');
93
+ expect(getEventEmoji('entitytag.removed')).toBe('🏷️');
94
+ });
95
+
96
+ it('returns appropriate emoji for job events', () => {
97
+ expect(getEventEmoji('job.completed')).toBe('🔗');
98
+ expect(getEventEmoji('job.started')).toBe('⚙️');
99
+ expect(getEventEmoji('job.failed')).toBe('❌');
100
+ });
101
+
102
+ it('returns default emoji for unknown', () => {
103
+ expect(getEventEmoji('unknown' as any)).toBe('📝');
104
+ });
105
+ });
106
+
107
+ describe('formatRelativeTime', () => {
108
+ it('returns justNow for recent timestamps', () => {
109
+ const now = new Date().toISOString();
110
+ expect(formatRelativeTime(now, t)).toBe('justNow');
111
+ });
112
+
113
+ it('returns minutesAgo for timestamps within an hour', () => {
114
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
115
+ const result = formatRelativeTime(fiveMinAgo, t);
116
+ expect(result).toContain('minutesAgo');
117
+ });
118
+
119
+ it('returns hoursAgo for timestamps within a day', () => {
120
+ const threeHoursAgo = new Date(Date.now() - 3 * 3600 * 1000).toISOString();
121
+ const result = formatRelativeTime(threeHoursAgo, t);
122
+ expect(result).toContain('hoursAgo');
123
+ });
124
+
125
+ it('returns daysAgo for timestamps within a week', () => {
126
+ const twoDaysAgo = new Date(Date.now() - 2 * 86400 * 1000).toISOString();
127
+ const result = formatRelativeTime(twoDaysAgo, t);
128
+ expect(result).toContain('daysAgo');
129
+ });
130
+
131
+ it('returns formatted date for older timestamps', () => {
132
+ const oldDate = new Date(Date.now() - 30 * 86400 * 1000).toISOString();
133
+ const result = formatRelativeTime(oldDate, t);
134
+ // Should be a locale date string, not a translation key
135
+ expect(result).not.toContain('Ago');
136
+ });
137
+ });
138
+
139
+ describe('getEventDisplayContent', () => {
140
+ it('returns resource name for resource.created', () => {
141
+ const event = {
142
+ event: { type: 'resource.created' as const, payload: { name: 'My Document' }, userId: 'u1', timestamp: '' },
143
+ } as any;
144
+ const result = getEventDisplayContent(event, [], []);
145
+ expect(result).toEqual({ exact: 'My Document', isQuoted: false, isTag: false });
146
+ });
147
+
148
+ it('returns resource name for resource.cloned', () => {
149
+ const event = {
150
+ event: { type: 'resource.cloned' as const, payload: { name: 'Cloned Doc' }, userId: 'u1', timestamp: '' },
151
+ } as any;
152
+ const result = getEventDisplayContent(event, [], []);
153
+ expect(result).toEqual({ exact: 'Cloned Doc', isQuoted: false, isTag: false });
154
+ });
155
+
156
+ it('returns entity type for entitytag events', () => {
157
+ const event = {
158
+ event: { type: 'entitytag.added' as const, payload: { entityType: 'Person' }, userId: 'u1', timestamp: '' },
159
+ } as any;
160
+ const result = getEventDisplayContent(event, [], []);
161
+ expect(result).toEqual({ exact: 'Person', isQuoted: false, isTag: true });
162
+ });
163
+
164
+ it('returns null for job.started', () => {
165
+ const event = {
166
+ event: { type: 'job.started' as const, payload: {}, userId: 'u1', timestamp: '' },
167
+ } as any;
168
+ expect(getEventDisplayContent(event, [], [])).toBeNull();
169
+ });
170
+
171
+ it('returns null for representation events', () => {
172
+ const event = {
173
+ event: { type: 'representation.added' as const, payload: {}, userId: 'u1', timestamp: '' },
174
+ } as any;
175
+ expect(getEventDisplayContent(event, [], [])).toBeNull();
176
+ });
177
+ });
178
+
179
+ describe('getEventEntityTypes', () => {
180
+ it('returns entity types from annotation.added with linking motivation', () => {
181
+ const event = {
182
+ event: {
183
+ type: 'annotation.added' as const,
184
+ payload: {
185
+ annotation: {
186
+ motivation: 'linking',
187
+ body: { entityTypes: ['Person', 'Place'] },
188
+ },
189
+ },
190
+ },
191
+ } as any;
192
+ expect(getEventEntityTypes(event)).toEqual(['Person', 'Place']);
193
+ });
194
+
195
+ it('returns empty array for non-linking annotations', () => {
196
+ const event = {
197
+ event: {
198
+ type: 'annotation.added' as const,
199
+ payload: {
200
+ annotation: { motivation: 'highlighting', body: null },
201
+ },
202
+ },
203
+ } as any;
204
+ expect(getEventEntityTypes(event)).toEqual([]);
205
+ });
206
+
207
+ it('returns empty array for non-annotation events', () => {
208
+ const event = {
209
+ event: { type: 'resource.created' as const, payload: { name: 'test' } },
210
+ } as any;
211
+ expect(getEventEntityTypes(event)).toEqual([]);
212
+ });
213
+ });
214
+
215
+ describe('getResourceCreationDetails', () => {
216
+ it('returns created details for resource.created', () => {
217
+ const event = {
218
+ event: {
219
+ type: 'resource.created' as const,
220
+ payload: { name: 'Doc', creationMethod: 'upload' },
221
+ userId: 'user-1',
222
+ timestamp: '',
223
+ },
224
+ } as any;
225
+ const result = getResourceCreationDetails(event);
226
+ expect(result).toEqual({
227
+ type: 'created',
228
+ method: 'upload',
229
+ userId: 'user-1',
230
+ metadata: undefined,
231
+ });
232
+ });
233
+
234
+ it('returns cloned details for resource.cloned', () => {
235
+ const event = {
236
+ event: {
237
+ type: 'resource.cloned' as const,
238
+ payload: { name: 'Clone', creationMethod: 'clone', parentResourceId: 'parent-1' },
239
+ userId: 'user-2',
240
+ timestamp: '',
241
+ },
242
+ } as any;
243
+ const result = getResourceCreationDetails(event);
244
+ expect(result).toEqual({
245
+ type: 'cloned',
246
+ method: 'clone',
247
+ userId: 'user-2',
248
+ sourceDocId: 'parent-1',
249
+ parentResourceId: 'parent-1',
250
+ metadata: undefined,
251
+ });
252
+ });
253
+
254
+ it('uses fallback method when creationMethod missing', () => {
255
+ const event = {
256
+ event: {
257
+ type: 'resource.created' as const,
258
+ payload: { name: 'Doc' },
259
+ userId: 'u1',
260
+ timestamp: '',
261
+ },
262
+ } as any;
263
+ expect(getResourceCreationDetails(event)?.method).toBe('unknown');
264
+ });
265
+
266
+ it('returns null for non-creation events', () => {
267
+ const event = {
268
+ event: { type: 'annotation.added' as const, payload: {} },
269
+ } as any;
270
+ expect(getResourceCreationDetails(event)).toBeNull();
271
+ });
272
+ });
273
+ });
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import React from 'react';
3
+ import { screen } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import { renderWithProviders, resetEventBusForTesting } from '../../../../test-utils';
6
+ import userEvent from '@testing-library/user-event';
7
+ import type { components } from '@semiont/core';
8
+
9
+ type Annotation = components['schemas']['Annotation'];
10
+
11
+ // Mock @semiont/api-client
12
+ vi.mock('@semiont/api-client', async () => {
13
+ const actual = await vi.importActual('@semiont/api-client');
14
+ return {
15
+ ...actual,
16
+ getAnnotationExactText: vi.fn(),
17
+ };
18
+ });
19
+
20
+ import { getAnnotationExactText } from '@semiont/api-client';
21
+ import type { MockedFunction } from 'vitest';
22
+ import { AssessmentEntry } from '../AssessmentEntry';
23
+
24
+ const mockGetAnnotationExactText = getAnnotationExactText as MockedFunction<typeof getAnnotationExactText>;
25
+
26
+ const createMockAssessment = (overrides?: Partial<Annotation>): Annotation => ({
27
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
28
+ id: 'assessment-1',
29
+ type: 'Annotation',
30
+ motivation: 'assessing',
31
+ creator: {
32
+ name: 'reviewer@example.com',
33
+ },
34
+ created: '2024-06-15T12:00:00Z',
35
+ modified: '2024-06-15T12:00:00Z',
36
+ target: {
37
+ source: 'resource-1',
38
+ selector: {
39
+ type: 'TextPositionSelector',
40
+ start: 0,
41
+ end: 50,
42
+ },
43
+ },
44
+ body: {
45
+ type: 'TextualBody',
46
+ value: 'This passage needs clarification',
47
+ },
48
+ ...overrides,
49
+ });
50
+
51
+ describe('AssessmentEntry', () => {
52
+ const defaultProps = {
53
+ assessment: createMockAssessment(),
54
+ isFocused: false,
55
+ };
56
+
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ resetEventBusForTesting();
60
+ mockGetAnnotationExactText.mockReturnValue('Selected passage text');
61
+ });
62
+
63
+ describe('Rendering', () => {
64
+ it('should render the selected text in quotes', () => {
65
+ renderWithProviders(<AssessmentEntry {...defaultProps} />);
66
+
67
+ expect(screen.getByText(/Selected passage text/)).toBeInTheDocument();
68
+ });
69
+
70
+ it('should render the assessment body text', () => {
71
+ renderWithProviders(<AssessmentEntry {...defaultProps} />);
72
+
73
+ expect(screen.getByText('This passage needs clarification')).toBeInTheDocument();
74
+ });
75
+
76
+ it('should handle a TextualBody directly on body', () => {
77
+ const assessment = createMockAssessment({
78
+ body: {
79
+ type: 'TextualBody',
80
+ value: 'Direct body assessment',
81
+ },
82
+ });
83
+
84
+ renderWithProviders(
85
+ <AssessmentEntry assessment={assessment} isFocused={false} />
86
+ );
87
+
88
+ expect(screen.getByText('Direct body assessment')).toBeInTheDocument();
89
+ });
90
+
91
+ it('should handle an array of bodies and find TextualBody', () => {
92
+ const assessment = createMockAssessment({
93
+ body: [
94
+ { type: 'TextualBody', value: 'Array body assessment' },
95
+ { type: 'TextualBody', value: 'Second body', purpose: 'tagging' },
96
+ ],
97
+ });
98
+
99
+ renderWithProviders(
100
+ <AssessmentEntry assessment={assessment} isFocused={false} />
101
+ );
102
+
103
+ expect(screen.getByText('Array body assessment')).toBeInTheDocument();
104
+ });
105
+
106
+ it('should truncate selected text at 100 characters', () => {
107
+ const longText = 'X'.repeat(150);
108
+ mockGetAnnotationExactText.mockReturnValue(longText);
109
+
110
+ renderWithProviders(<AssessmentEntry {...defaultProps} />);
111
+
112
+ expect(screen.getByText(new RegExp(`"${'X'.repeat(100)}`))).toBeInTheDocument();
113
+ expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
114
+ });
115
+
116
+ it('should show creator name', () => {
117
+ renderWithProviders(<AssessmentEntry {...defaultProps} />);
118
+
119
+ expect(screen.getByText(/reviewer@example.com/)).toBeInTheDocument();
120
+ });
121
+
122
+ it('should show "Unknown" for missing creator', () => {
123
+ const assessment = createMockAssessment();
124
+ delete (assessment as Record<string, unknown>).creator;
125
+
126
+ renderWithProviders(
127
+ <AssessmentEntry assessment={assessment} isFocused={false} />
128
+ );
129
+
130
+ expect(screen.getByText(/Unknown/)).toBeInTheDocument();
131
+ });
132
+
133
+ it('should handle missing body gracefully', () => {
134
+ const assessment = createMockAssessment();
135
+ delete (assessment as Record<string, unknown>).body;
136
+
137
+ const { container } = renderWithProviders(
138
+ <AssessmentEntry assessment={assessment} isFocused={false} />
139
+ );
140
+
141
+ // Body section should not render
142
+ expect(container.querySelector('.semiont-annotation-entry__body')).not.toBeInTheDocument();
143
+ });
144
+
145
+ it('should not render quote section when selectedText is empty', () => {
146
+ mockGetAnnotationExactText.mockReturnValue('');
147
+
148
+ const { container } = renderWithProviders(<AssessmentEntry {...defaultProps} />);
149
+
150
+ expect(container.querySelector('.semiont-annotation-entry__quote')).not.toBeInTheDocument();
151
+ });
152
+
153
+ it('should format relative time for recent assessments', () => {
154
+ const recentAssessment = createMockAssessment({
155
+ created: new Date(Date.now() - 30000).toISOString(),
156
+ });
157
+
158
+ renderWithProviders(
159
+ <AssessmentEntry assessment={recentAssessment} isFocused={false} />
160
+ );
161
+
162
+ expect(screen.getByText(/just now/)).toBeInTheDocument();
163
+ });
164
+ });
165
+
166
+ describe('Interactions', () => {
167
+ it('should emit browse:click on click', async () => {
168
+ const clickHandler = vi.fn();
169
+
170
+ const { container, eventBus } = renderWithProviders(
171
+ <AssessmentEntry {...defaultProps} />,
172
+ { returnEventBus: true }
173
+ );
174
+
175
+ const subscription = eventBus!.get('browse:click').subscribe(clickHandler);
176
+
177
+ const entry = container.firstChild as HTMLElement;
178
+ await userEvent.click(entry);
179
+
180
+ expect(clickHandler).toHaveBeenCalledWith({
181
+ annotationId: 'assessment-1',
182
+ motivation: 'assessing',
183
+ });
184
+
185
+ subscription.unsubscribe();
186
+ });
187
+ });
188
+
189
+ describe('Hover state', () => {
190
+ it('should apply pulse class when isHovered is true', () => {
191
+ const { container } = renderWithProviders(
192
+ <AssessmentEntry {...defaultProps} isHovered={true} />
193
+ );
194
+
195
+ const entry = container.firstChild as HTMLElement;
196
+ expect(entry).toHaveClass('semiont-annotation-pulse');
197
+ });
198
+
199
+ it('should not apply pulse class when isHovered is false', () => {
200
+ const { container } = renderWithProviders(
201
+ <AssessmentEntry {...defaultProps} isHovered={false} />
202
+ );
203
+
204
+ const entry = container.firstChild as HTMLElement;
205
+ expect(entry).not.toHaveClass('semiont-annotation-pulse');
206
+ });
207
+ });
208
+
209
+ describe('Focus state', () => {
210
+ it('should set data-focused to true when focused', () => {
211
+ const { container } = renderWithProviders(
212
+ <AssessmentEntry {...defaultProps} isFocused={true} />
213
+ );
214
+
215
+ const entry = container.firstChild as HTMLElement;
216
+ expect(entry).toHaveAttribute('data-focused', 'true');
217
+ });
218
+
219
+ it('should set data-type to assessment', () => {
220
+ const { container } = renderWithProviders(<AssessmentEntry {...defaultProps} />);
221
+
222
+ const entry = container.firstChild as HTMLElement;
223
+ expect(entry).toHaveAttribute('data-type', 'assessment');
224
+ });
225
+ });
226
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import React from 'react';
3
+ import { screen, fireEvent } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import { renderWithProviders, resetEventBusForTesting } from '../../../../test-utils';
6
+ import userEvent from '@testing-library/user-event';
7
+ import type { components } from '@semiont/core';
8
+
9
+ type Annotation = components['schemas']['Annotation'];
10
+
11
+ // Mock @semiont/api-client
12
+ vi.mock('@semiont/api-client', async () => {
13
+ const actual = await vi.importActual('@semiont/api-client');
14
+ return {
15
+ ...actual,
16
+ getAnnotationExactText: vi.fn(),
17
+ };
18
+ });
19
+
20
+ import { getAnnotationExactText } from '@semiont/api-client';
21
+ import type { MockedFunction } from 'vitest';
22
+ import { HighlightEntry } from '../HighlightEntry';
23
+
24
+ const mockGetAnnotationExactText = getAnnotationExactText as MockedFunction<typeof getAnnotationExactText>;
25
+
26
+ const createMockHighlight = (overrides?: Partial<Annotation>): Annotation => ({
27
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
28
+ id: 'highlight-1',
29
+ type: 'Annotation',
30
+ motivation: 'highlighting',
31
+ creator: {
32
+ name: 'alice@example.com',
33
+ },
34
+ created: '2024-06-15T12:00:00Z',
35
+ modified: '2024-06-15T12:00:00Z',
36
+ target: {
37
+ source: 'resource-1',
38
+ selector: {
39
+ type: 'TextPositionSelector',
40
+ start: 0,
41
+ end: 50,
42
+ },
43
+ },
44
+ ...overrides,
45
+ });
46
+
47
+ describe('HighlightEntry', () => {
48
+ const defaultProps = {
49
+ highlight: createMockHighlight(),
50
+ isFocused: false,
51
+ };
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ resetEventBusForTesting();
56
+ mockGetAnnotationExactText.mockReturnValue('This is the highlighted text');
57
+ });
58
+
59
+ describe('Rendering', () => {
60
+ it('should render the selected text in quotes', () => {
61
+ renderWithProviders(<HighlightEntry {...defaultProps} />);
62
+
63
+ expect(screen.getByText(/This is the highlighted text/)).toBeInTheDocument();
64
+ });
65
+
66
+ it('should truncate text over 200 characters', () => {
67
+ const longText = 'A'.repeat(250);
68
+ mockGetAnnotationExactText.mockReturnValue(longText);
69
+
70
+ renderWithProviders(<HighlightEntry {...defaultProps} />);
71
+
72
+ // Should show first 200 chars followed by ellipsis
73
+ expect(screen.getByText(new RegExp(`"${'A'.repeat(200)}`))).toBeInTheDocument();
74
+ expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
75
+ });
76
+
77
+ it('should not truncate text at exactly 200 characters', () => {
78
+ const exactText = 'B'.repeat(200);
79
+ mockGetAnnotationExactText.mockReturnValue(exactText);
80
+
81
+ const { container } = renderWithProviders(<HighlightEntry {...defaultProps} />);
82
+
83
+ const quote = container.querySelector('.semiont-annotation-entry__quote');
84
+ expect(quote).toBeInTheDocument();
85
+ expect(quote!.textContent).not.toContain('...');
86
+ });
87
+
88
+ it('should show creator name', () => {
89
+ renderWithProviders(<HighlightEntry {...defaultProps} />);
90
+
91
+ expect(screen.getByText(/alice@example.com/)).toBeInTheDocument();
92
+ });
93
+
94
+ it('should show "Unknown" for missing creator', () => {
95
+ const highlight = createMockHighlight();
96
+ delete (highlight as Record<string, unknown>).creator;
97
+
98
+ renderWithProviders(
99
+ <HighlightEntry highlight={highlight} isFocused={false} />
100
+ );
101
+
102
+ expect(screen.getByText(/Unknown/)).toBeInTheDocument();
103
+ });
104
+
105
+ it('should format relative time', () => {
106
+ const recentHighlight = createMockHighlight({
107
+ created: new Date(Date.now() - 30000).toISOString(),
108
+ });
109
+
110
+ renderWithProviders(
111
+ <HighlightEntry highlight={recentHighlight} isFocused={false} />
112
+ );
113
+
114
+ expect(screen.getByText(/just now/)).toBeInTheDocument();
115
+ });
116
+
117
+ it('should not render quote section when selectedText is empty', () => {
118
+ mockGetAnnotationExactText.mockReturnValue('');
119
+
120
+ const { container } = renderWithProviders(<HighlightEntry {...defaultProps} />);
121
+
122
+ expect(container.querySelector('.semiont-annotation-entry__quote')).not.toBeInTheDocument();
123
+ });
124
+ });
125
+
126
+ describe('Interactions', () => {
127
+ it('should emit browse:click on click', async () => {
128
+ const clickHandler = vi.fn();
129
+
130
+ const { container, eventBus } = renderWithProviders(
131
+ <HighlightEntry {...defaultProps} />,
132
+ { returnEventBus: true }
133
+ );
134
+
135
+ const subscription = eventBus!.get('browse:click').subscribe(clickHandler);
136
+
137
+ const entry = container.firstChild as HTMLElement;
138
+ await userEvent.click(entry);
139
+
140
+ expect(clickHandler).toHaveBeenCalledWith({
141
+ annotationId: 'highlight-1',
142
+ motivation: 'highlighting',
143
+ });
144
+
145
+ subscription.unsubscribe();
146
+ });
147
+ });
148
+
149
+ describe('Hover state', () => {
150
+ it('should apply pulse class when isHovered is true', () => {
151
+ const { container } = renderWithProviders(
152
+ <HighlightEntry {...defaultProps} isHovered={true} />
153
+ );
154
+
155
+ const entry = container.firstChild as HTMLElement;
156
+ expect(entry).toHaveClass('semiont-annotation-pulse');
157
+ });
158
+
159
+ it('should not apply pulse class when isHovered is false', () => {
160
+ const { container } = renderWithProviders(
161
+ <HighlightEntry {...defaultProps} isHovered={false} />
162
+ );
163
+
164
+ const entry = container.firstChild as HTMLElement;
165
+ expect(entry).not.toHaveClass('semiont-annotation-pulse');
166
+ });
167
+ });
168
+
169
+ describe('Focus state', () => {
170
+ it('should set data-focused to true when focused', () => {
171
+ const { container } = renderWithProviders(
172
+ <HighlightEntry {...defaultProps} isFocused={true} />
173
+ );
174
+
175
+ const entry = container.firstChild as HTMLElement;
176
+ expect(entry).toHaveAttribute('data-focused', 'true');
177
+ });
178
+
179
+ it('should set data-focused to false when not focused', () => {
180
+ const { container } = renderWithProviders(
181
+ <HighlightEntry {...defaultProps} isFocused={false} />
182
+ );
183
+
184
+ const entry = container.firstChild as HTMLElement;
185
+ expect(entry).toHaveAttribute('data-focused', 'false');
186
+ });
187
+ });
188
+ });