@semiont/react-ui 0.2.36 → 0.2.38
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/index.d.mts +8 -0
- package/dist/index.mjs +252 -166
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/CodeMirrorRenderer.tsx +71 -203
- package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +142 -0
- package/src/components/__tests__/LiveRegion.hooks.test.tsx +79 -0
- package/src/components/__tests__/ResizeHandle.test.tsx +165 -0
- package/src/components/__tests__/SessionExpiryBanner.test.tsx +123 -0
- package/src/components/__tests__/StatusDisplay.test.tsx +160 -0
- package/src/components/__tests__/Toolbar.test.tsx +110 -0
- package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +285 -0
- package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +273 -0
- package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +90 -0
- package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +129 -0
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +180 -0
- package/src/components/navigation/__tests__/ObservableLink.test.tsx +90 -0
- package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +169 -0
- package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +371 -0
- package/src/components/resource/AnnotateView.tsx +27 -153
- package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
- package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
- package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
- package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
- package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
- package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
- package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
- package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
- package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
- package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
|
@@ -0,0 +1,271 @@
|
|
|
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 } from '../../../../test-utils';
|
|
6
|
+
import type { components } from '@semiont/core';
|
|
7
|
+
|
|
8
|
+
type Annotation = components['schemas']['Annotation'];
|
|
9
|
+
|
|
10
|
+
// Stable mock functions defined outside vi.mock to avoid re-render loops
|
|
11
|
+
const mockIsBodyResolved = vi.fn();
|
|
12
|
+
const mockGetEntityTypes = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock('@semiont/api-client', async () => {
|
|
15
|
+
const actual = await vi.importActual('@semiont/api-client');
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
isBodyResolved: (...args: unknown[]) => mockIsBodyResolved(...args),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.mock('@semiont/ontology', () => ({
|
|
23
|
+
getEntityTypes: (...args: unknown[]) => mockGetEntityTypes(...args),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { StatisticsPanel } from '../StatisticsPanel';
|
|
27
|
+
|
|
28
|
+
const createMockAnnotation = (overrides?: Partial<Annotation>): Annotation => ({
|
|
29
|
+
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
|
30
|
+
id: 'http://example.com/annotations/1',
|
|
31
|
+
type: 'Annotation',
|
|
32
|
+
motivation: 'linking',
|
|
33
|
+
created: '2024-06-15T12:00:00Z',
|
|
34
|
+
modified: '2024-06-15T12:00:00Z',
|
|
35
|
+
target: {
|
|
36
|
+
source: 'http://example.com/resources/1',
|
|
37
|
+
selector: {
|
|
38
|
+
type: 'TextQuoteSelector',
|
|
39
|
+
exact: 'some text',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('StatisticsPanel', () => {
|
|
46
|
+
const emptyProps = {
|
|
47
|
+
highlights: [] as Annotation[],
|
|
48
|
+
comments: [] as Annotation[],
|
|
49
|
+
assessments: [] as Annotation[],
|
|
50
|
+
references: [] as Annotation[],
|
|
51
|
+
tags: [] as Annotation[],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
mockIsBodyResolved.mockReturnValue(false);
|
|
57
|
+
mockGetEntityTypes.mockReturnValue([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Rendering counts', () => {
|
|
61
|
+
it('should render zero counts for all categories when empty', () => {
|
|
62
|
+
renderWithProviders(<StatisticsPanel {...emptyProps} />);
|
|
63
|
+
|
|
64
|
+
// Title
|
|
65
|
+
expect(screen.getByText('StatisticsPanel.title')).toBeInTheDocument();
|
|
66
|
+
|
|
67
|
+
// All counts should be 0
|
|
68
|
+
const values = screen.getAllByText('0');
|
|
69
|
+
// highlights, comments, assessments, tags, references, stub, resolved = 7 zeros
|
|
70
|
+
expect(values.length).toBe(7);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should render correct highlight count', () => {
|
|
74
|
+
const props = {
|
|
75
|
+
...emptyProps,
|
|
76
|
+
highlights: [createMockAnnotation({ id: 'h1' }), createMockAnnotation({ id: 'h2' }), createMockAnnotation({ id: 'h3' })],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
renderWithProviders(<StatisticsPanel {...props} />);
|
|
80
|
+
|
|
81
|
+
expect(screen.getByText('StatisticsPanel.highlights')).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByText('3')).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should render correct comment count', () => {
|
|
86
|
+
const props = {
|
|
87
|
+
...emptyProps,
|
|
88
|
+
comments: [createMockAnnotation({ id: 'c1' }), createMockAnnotation({ id: 'c2' })],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
renderWithProviders(<StatisticsPanel {...props} />);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText('StatisticsPanel.comments')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should render correct assessment count', () => {
|
|
98
|
+
const props = {
|
|
99
|
+
...emptyProps,
|
|
100
|
+
assessments: [createMockAnnotation({ id: 'a1' })],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
renderWithProviders(<StatisticsPanel {...props} />);
|
|
104
|
+
|
|
105
|
+
expect(screen.getByText('StatisticsPanel.assessments')).toBeInTheDocument();
|
|
106
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should render correct tag count', () => {
|
|
110
|
+
const props = {
|
|
111
|
+
...emptyProps,
|
|
112
|
+
tags: [
|
|
113
|
+
createMockAnnotation({ id: 't1' }),
|
|
114
|
+
createMockAnnotation({ id: 't2' }),
|
|
115
|
+
createMockAnnotation({ id: 't3' }),
|
|
116
|
+
createMockAnnotation({ id: 't4' }),
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
renderWithProviders(<StatisticsPanel {...props} />);
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText('StatisticsPanel.tags')).toBeInTheDocument();
|
|
123
|
+
expect(screen.getByText('4')).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should render correct total reference count', () => {
|
|
127
|
+
const refs = [createMockAnnotation({ id: 'r1' }), createMockAnnotation({ id: 'r2' })];
|
|
128
|
+
|
|
129
|
+
const { container } = renderWithProviders(<StatisticsPanel {...emptyProps} references={refs} />);
|
|
130
|
+
|
|
131
|
+
expect(screen.getByText('StatisticsPanel.references')).toBeInTheDocument();
|
|
132
|
+
// The references item has the total count as its direct .semiont-statistics-panel__value child
|
|
133
|
+
const referencesItem = screen.getByText('StatisticsPanel.references').closest('.semiont-statistics-panel__item');
|
|
134
|
+
const totalValue = referencesItem!.querySelector(':scope > .semiont-statistics-panel__value');
|
|
135
|
+
expect(totalValue!.textContent).toBe('2');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Reference sub-categories', () => {
|
|
140
|
+
it('should show stub and resolved counts', () => {
|
|
141
|
+
const refs = [
|
|
142
|
+
createMockAnnotation({ id: 'r1' }),
|
|
143
|
+
createMockAnnotation({ id: 'r2' }),
|
|
144
|
+
createMockAnnotation({ id: 'r3' }),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
// r1 resolved, r2 and r3 are stubs
|
|
148
|
+
mockIsBodyResolved.mockImplementation((body: unknown) => {
|
|
149
|
+
// We can distinguish by the call order
|
|
150
|
+
return false;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Make first call return true, rest false
|
|
154
|
+
mockIsBodyResolved
|
|
155
|
+
.mockReturnValueOnce(true) // r1 stub check
|
|
156
|
+
.mockReturnValueOnce(false) // r2 stub check
|
|
157
|
+
.mockReturnValueOnce(false) // r3 stub check
|
|
158
|
+
.mockReturnValueOnce(true) // r1 resolved check
|
|
159
|
+
.mockReturnValueOnce(false) // r2 resolved check
|
|
160
|
+
.mockReturnValueOnce(false); // r3 resolved check
|
|
161
|
+
|
|
162
|
+
renderWithProviders(<StatisticsPanel {...emptyProps} references={refs} />);
|
|
163
|
+
|
|
164
|
+
expect(screen.getByText('StatisticsPanel.stub')).toBeInTheDocument();
|
|
165
|
+
expect(screen.getByText('StatisticsPanel.resolved')).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should count all as resolved when isBodyResolved returns true', () => {
|
|
169
|
+
const refs = [
|
|
170
|
+
createMockAnnotation({ id: 'r1' }),
|
|
171
|
+
createMockAnnotation({ id: 'r2' }),
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
mockIsBodyResolved.mockReturnValue(true);
|
|
175
|
+
|
|
176
|
+
renderWithProviders(<StatisticsPanel {...emptyProps} references={refs} />);
|
|
177
|
+
|
|
178
|
+
// stub count = 0, resolved count = 2
|
|
179
|
+
expect(screen.getByText('StatisticsPanel.stub')).toBeInTheDocument();
|
|
180
|
+
expect(screen.getByText('StatisticsPanel.resolved')).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should count all as stubs when isBodyResolved returns false', () => {
|
|
184
|
+
const refs = [
|
|
185
|
+
createMockAnnotation({ id: 'r1' }),
|
|
186
|
+
createMockAnnotation({ id: 'r2' }),
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
mockIsBodyResolved.mockReturnValue(false);
|
|
190
|
+
|
|
191
|
+
renderWithProviders(<StatisticsPanel {...emptyProps} references={refs} />);
|
|
192
|
+
|
|
193
|
+
expect(screen.getByText('StatisticsPanel.stub')).toBeInTheDocument();
|
|
194
|
+
expect(screen.getByText('StatisticsPanel.resolved')).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('Entity types', () => {
|
|
199
|
+
it('should not render entity types section when no entity types exist', () => {
|
|
200
|
+
mockGetEntityTypes.mockReturnValue([]);
|
|
201
|
+
|
|
202
|
+
const { container } = renderWithProviders(<StatisticsPanel {...emptyProps} />);
|
|
203
|
+
|
|
204
|
+
expect(container.querySelector('.semiont-statistics-panel__entity-types')).not.toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should render entity types with counts', () => {
|
|
208
|
+
const refs = [
|
|
209
|
+
createMockAnnotation({ id: 'r1' }),
|
|
210
|
+
createMockAnnotation({ id: 'r2' }),
|
|
211
|
+
createMockAnnotation({ id: 'r3' }),
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
mockGetEntityTypes
|
|
215
|
+
.mockReturnValueOnce(['Person', 'Organization'])
|
|
216
|
+
.mockReturnValueOnce(['Person'])
|
|
217
|
+
.mockReturnValueOnce(['Location']);
|
|
218
|
+
|
|
219
|
+
renderWithProviders(<StatisticsPanel {...emptyProps} references={refs} />);
|
|
220
|
+
|
|
221
|
+
expect(screen.getByText('StatisticsPanel.entityTypes')).toBeInTheDocument();
|
|
222
|
+
expect(screen.getByText('Person')).toBeInTheDocument();
|
|
223
|
+
expect(screen.getByText('Organization')).toBeInTheDocument();
|
|
224
|
+
expect(screen.getByText('Location')).toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should sort entity types by count descending', () => {
|
|
228
|
+
const refs = [
|
|
229
|
+
createMockAnnotation({ id: 'r1' }),
|
|
230
|
+
createMockAnnotation({ id: 'r2' }),
|
|
231
|
+
createMockAnnotation({ id: 'r3' }),
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
// Person appears 3 times, Location 1 time
|
|
235
|
+
mockGetEntityTypes
|
|
236
|
+
.mockReturnValueOnce(['Person'])
|
|
237
|
+
.mockReturnValueOnce(['Person'])
|
|
238
|
+
.mockReturnValueOnce(['Person', 'Location']);
|
|
239
|
+
|
|
240
|
+
const { container } = renderWithProviders(<StatisticsPanel {...emptyProps} references={refs} />);
|
|
241
|
+
|
|
242
|
+
const entityItems = container.querySelectorAll('.semiont-statistics-panel__entity-item');
|
|
243
|
+
expect(entityItems.length).toBe(2);
|
|
244
|
+
|
|
245
|
+
// First should be Person (count 3), second Location (count 1)
|
|
246
|
+
expect(entityItems[0].querySelector('.semiont-statistics-panel__entity-name')!.textContent).toBe('Person');
|
|
247
|
+
expect(entityItems[1].querySelector('.semiont-statistics-panel__entity-name')!.textContent).toBe('Location');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('Mixed annotation counts', () => {
|
|
252
|
+
it('should render all categories with their respective counts simultaneously', () => {
|
|
253
|
+
const props = {
|
|
254
|
+
highlights: [createMockAnnotation({ id: 'h1' })],
|
|
255
|
+
comments: [createMockAnnotation({ id: 'c1' }), createMockAnnotation({ id: 'c2' })],
|
|
256
|
+
assessments: [createMockAnnotation({ id: 'a1' }), createMockAnnotation({ id: 'a2' }), createMockAnnotation({ id: 'a3' })],
|
|
257
|
+
references: [createMockAnnotation({ id: 'r1' })],
|
|
258
|
+
tags: [createMockAnnotation({ id: 't1' }), createMockAnnotation({ id: 't2' })],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
renderWithProviders(<StatisticsPanel {...props} />);
|
|
262
|
+
|
|
263
|
+
// Check that labels are present
|
|
264
|
+
expect(screen.getByText('StatisticsPanel.highlights')).toBeInTheDocument();
|
|
265
|
+
expect(screen.getByText('StatisticsPanel.comments')).toBeInTheDocument();
|
|
266
|
+
expect(screen.getByText('StatisticsPanel.assessments')).toBeInTheDocument();
|
|
267
|
+
expect(screen.getByText('StatisticsPanel.references')).toBeInTheDocument();
|
|
268
|
+
expect(screen.getByText('StatisticsPanel.tags')).toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
// Mock @semiont/ontology
|
|
21
|
+
vi.mock('@semiont/ontology', () => ({
|
|
22
|
+
getTagCategory: vi.fn(),
|
|
23
|
+
getTagSchemaId: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// Mock tag-schemas
|
|
27
|
+
vi.mock('../../../../lib/tag-schemas', () => ({
|
|
28
|
+
getTagSchema: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { getAnnotationExactText } from '@semiont/api-client';
|
|
32
|
+
import { getTagCategory, getTagSchemaId } from '@semiont/ontology';
|
|
33
|
+
import { getTagSchema } from '../../../../lib/tag-schemas';
|
|
34
|
+
import type { MockedFunction } from 'vitest';
|
|
35
|
+
import { TagEntry } from '../TagEntry';
|
|
36
|
+
|
|
37
|
+
const mockGetAnnotationExactText = getAnnotationExactText as MockedFunction<typeof getAnnotationExactText>;
|
|
38
|
+
const mockGetTagCategory = getTagCategory as MockedFunction<typeof getTagCategory>;
|
|
39
|
+
const mockGetTagSchemaId = getTagSchemaId as MockedFunction<typeof getTagSchemaId>;
|
|
40
|
+
const mockGetTagSchema = getTagSchema as MockedFunction<typeof getTagSchema>;
|
|
41
|
+
|
|
42
|
+
const createMockTag = (overrides?: Partial<Annotation>): Annotation => ({
|
|
43
|
+
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
|
44
|
+
id: 'tag-1',
|
|
45
|
+
type: 'Annotation',
|
|
46
|
+
motivation: 'tagging',
|
|
47
|
+
creator: {
|
|
48
|
+
name: 'tagger@example.com',
|
|
49
|
+
},
|
|
50
|
+
created: '2024-06-15T12:00:00Z',
|
|
51
|
+
modified: '2024-06-15T12:00:00Z',
|
|
52
|
+
target: {
|
|
53
|
+
source: 'resource-1',
|
|
54
|
+
selector: {
|
|
55
|
+
type: 'TextPositionSelector',
|
|
56
|
+
start: 10,
|
|
57
|
+
end: 30,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
body: {
|
|
61
|
+
type: 'TextualBody',
|
|
62
|
+
value: 'Person',
|
|
63
|
+
purpose: 'tagging',
|
|
64
|
+
},
|
|
65
|
+
...overrides,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('TagEntry', () => {
|
|
69
|
+
const defaultProps = {
|
|
70
|
+
tag: createMockTag(),
|
|
71
|
+
isFocused: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.clearAllMocks();
|
|
76
|
+
resetEventBusForTesting();
|
|
77
|
+
mockGetAnnotationExactText.mockReturnValue('Tagged text content');
|
|
78
|
+
mockGetTagCategory.mockReturnValue('Entity');
|
|
79
|
+
mockGetTagSchemaId.mockReturnValue(null);
|
|
80
|
+
mockGetTagSchema.mockReturnValue(null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('Rendering', () => {
|
|
84
|
+
it('should render the category badge', () => {
|
|
85
|
+
renderWithProviders(<TagEntry {...defaultProps} />);
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText('Entity')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should render the selected text in quotes', () => {
|
|
91
|
+
renderWithProviders(<TagEntry {...defaultProps} />);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText(/Tagged text content/)).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should truncate text over 150 characters', () => {
|
|
97
|
+
const longText = 'C'.repeat(200);
|
|
98
|
+
mockGetAnnotationExactText.mockReturnValue(longText);
|
|
99
|
+
|
|
100
|
+
renderWithProviders(<TagEntry {...defaultProps} />);
|
|
101
|
+
|
|
102
|
+
expect(screen.getByText(new RegExp(`"${'C'.repeat(150)}`))).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should not truncate text at exactly 150 characters', () => {
|
|
107
|
+
const exactText = 'D'.repeat(150);
|
|
108
|
+
mockGetAnnotationExactText.mockReturnValue(exactText);
|
|
109
|
+
|
|
110
|
+
const { container } = renderWithProviders(<TagEntry {...defaultProps} />);
|
|
111
|
+
|
|
112
|
+
const quote = container.querySelector('.semiont-annotation-entry__quote');
|
|
113
|
+
expect(quote).toBeInTheDocument();
|
|
114
|
+
expect(quote!.textContent).not.toContain('...');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should render schema name when available', () => {
|
|
118
|
+
mockGetTagSchemaId.mockReturnValue('schema-ner-v1');
|
|
119
|
+
mockGetTagSchema.mockReturnValue({
|
|
120
|
+
id: 'schema-ner-v1',
|
|
121
|
+
name: 'Named Entity Recognition',
|
|
122
|
+
domain: 'nlp',
|
|
123
|
+
version: '1.0',
|
|
124
|
+
categories: [],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
renderWithProviders(<TagEntry {...defaultProps} />);
|
|
128
|
+
|
|
129
|
+
expect(screen.getByText('Named Entity Recognition')).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should not render schema name when schema is not found', () => {
|
|
133
|
+
mockGetTagSchemaId.mockReturnValue('unknown-schema');
|
|
134
|
+
mockGetTagSchema.mockReturnValue(null);
|
|
135
|
+
|
|
136
|
+
const { container } = renderWithProviders(<TagEntry {...defaultProps} />);
|
|
137
|
+
|
|
138
|
+
expect(container.querySelector('.semiont-annotation-entry__meta')).not.toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should render category badge with correct data-variant', () => {
|
|
142
|
+
const { container } = renderWithProviders(<TagEntry {...defaultProps} />);
|
|
143
|
+
|
|
144
|
+
const badge = container.querySelector('.semiont-tag-badge');
|
|
145
|
+
expect(badge).toBeInTheDocument();
|
|
146
|
+
expect(badge).toHaveAttribute('data-variant', 'tag');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Interactions', () => {
|
|
151
|
+
it('should emit browse:click on click', async () => {
|
|
152
|
+
const clickHandler = vi.fn();
|
|
153
|
+
|
|
154
|
+
const { container, eventBus } = renderWithProviders(
|
|
155
|
+
<TagEntry {...defaultProps} />,
|
|
156
|
+
{ returnEventBus: true }
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const subscription = eventBus!.get('browse:click').subscribe(clickHandler);
|
|
160
|
+
|
|
161
|
+
const entry = container.firstChild as HTMLElement;
|
|
162
|
+
await userEvent.click(entry);
|
|
163
|
+
|
|
164
|
+
expect(clickHandler).toHaveBeenCalledWith({
|
|
165
|
+
annotationId: 'tag-1',
|
|
166
|
+
motivation: 'tagging',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
subscription.unsubscribe();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('Hover state', () => {
|
|
174
|
+
it('should apply pulse class when isHovered is true', () => {
|
|
175
|
+
const { container } = renderWithProviders(
|
|
176
|
+
<TagEntry {...defaultProps} isHovered={true} />
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const entry = container.firstChild as HTMLElement;
|
|
180
|
+
expect(entry).toHaveClass('semiont-annotation-pulse');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should not apply pulse class when isHovered is false', () => {
|
|
184
|
+
const { container } = renderWithProviders(
|
|
185
|
+
<TagEntry {...defaultProps} isHovered={false} />
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const entry = container.firstChild as HTMLElement;
|
|
189
|
+
expect(entry).not.toHaveClass('semiont-annotation-pulse');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('Focus state', () => {
|
|
194
|
+
it('should set data-focused to true when focused', () => {
|
|
195
|
+
const { container } = renderWithProviders(
|
|
196
|
+
<TagEntry {...defaultProps} isFocused={true} />
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const entry = container.firstChild as HTMLElement;
|
|
200
|
+
expect(entry).toHaveAttribute('data-focused', 'true');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should set data-type to tag', () => {
|
|
204
|
+
const { container } = renderWithProviders(<TagEntry {...defaultProps} />);
|
|
205
|
+
|
|
206
|
+
const entry = container.firstChild as HTMLElement;
|
|
207
|
+
expect(entry).toHaveAttribute('data-type', 'tag');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import { renderWithProviders, resetEventBusForTesting } from '../../../test-utils';
|
|
5
|
+
import '@testing-library/jest-dom';
|
|
6
|
+
import { SettingsPanel } from '../SettingsPanel';
|
|
7
|
+
|
|
8
|
+
// Mock LiveRegion
|
|
9
|
+
vi.mock('../../LiveRegion', () => ({
|
|
10
|
+
useLanguageChangeAnnouncements: vi.fn(() => ({
|
|
11
|
+
announceLanguageChanging: vi.fn(),
|
|
12
|
+
announceLanguageChanged: vi.fn(),
|
|
13
|
+
})),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock LOCALES from api-client
|
|
17
|
+
vi.mock('@semiont/api-client', async () => {
|
|
18
|
+
const actual = await vi.importActual('@semiont/api-client');
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
LOCALES: [
|
|
22
|
+
{ code: 'en', nativeName: 'English' },
|
|
23
|
+
{ code: 'de', nativeName: 'Deutsch' },
|
|
24
|
+
{ code: 'fr', nativeName: 'Français' },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('SettingsPanel', () => {
|
|
30
|
+
const defaultProps = {
|
|
31
|
+
showLineNumbers: true,
|
|
32
|
+
theme: 'light' as const,
|
|
33
|
+
locale: 'en',
|
|
34
|
+
hoverDelayMs: 150,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
resetEventBusForTesting();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders settings title', () => {
|
|
43
|
+
renderWithProviders(<SettingsPanel {...defaultProps} />);
|
|
44
|
+
expect(screen.getByText('Settings.title')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('line numbers toggle', () => {
|
|
48
|
+
it('renders line numbers toggle', () => {
|
|
49
|
+
renderWithProviders(<SettingsPanel {...defaultProps} />);
|
|
50
|
+
expect(screen.getByText('Settings.lineNumbers')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows toggle as checked when line numbers enabled', () => {
|
|
54
|
+
renderWithProviders(<SettingsPanel {...defaultProps} showLineNumbers={true} />);
|
|
55
|
+
const toggle = screen.getByRole('switch');
|
|
56
|
+
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('shows toggle as unchecked when line numbers disabled', () => {
|
|
60
|
+
renderWithProviders(<SettingsPanel {...defaultProps} showLineNumbers={false} />);
|
|
61
|
+
const toggle = screen.getByRole('switch');
|
|
62
|
+
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('emits settings:line-numbers-toggled on toggle click', () => {
|
|
66
|
+
const handler = vi.fn();
|
|
67
|
+
const { eventBus } = renderWithProviders(
|
|
68
|
+
<SettingsPanel {...defaultProps} />,
|
|
69
|
+
{ returnEventBus: true }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const sub = eventBus!.get('settings:line-numbers-toggled').subscribe(handler);
|
|
73
|
+
fireEvent.click(screen.getByRole('switch'));
|
|
74
|
+
expect(handler).toHaveBeenCalled();
|
|
75
|
+
sub.unsubscribe();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('theme selection', () => {
|
|
80
|
+
it('renders theme buttons', () => {
|
|
81
|
+
renderWithProviders(<SettingsPanel {...defaultProps} />);
|
|
82
|
+
expect(screen.getByText(/Settings.themeLight/)).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByText(/Settings.themeDark/)).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByText(/Settings.themeSystem/)).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('marks active theme as pressed', () => {
|
|
88
|
+
renderWithProviders(<SettingsPanel {...defaultProps} theme="dark" />);
|
|
89
|
+
const darkButton = screen.getByText(/Settings.themeDark/);
|
|
90
|
+
expect(darkButton).toHaveAttribute('aria-pressed', 'true');
|
|
91
|
+
|
|
92
|
+
const lightButton = screen.getByText(/Settings.themeLight/);
|
|
93
|
+
expect(lightButton).toHaveAttribute('aria-pressed', 'false');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('emits settings:theme-changed on theme button click', () => {
|
|
97
|
+
const handler = vi.fn();
|
|
98
|
+
const { eventBus } = renderWithProviders(
|
|
99
|
+
<SettingsPanel {...defaultProps} />,
|
|
100
|
+
{ returnEventBus: true }
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const sub = eventBus!.get('settings:theme-changed').subscribe(handler);
|
|
104
|
+
fireEvent.click(screen.getByText(/Settings.themeDark/));
|
|
105
|
+
expect(handler).toHaveBeenCalledWith({ theme: 'dark' });
|
|
106
|
+
sub.unsubscribe();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('language selection', () => {
|
|
111
|
+
it('renders language select with options', () => {
|
|
112
|
+
renderWithProviders(<SettingsPanel {...defaultProps} />);
|
|
113
|
+
const select = screen.getByLabelText('Settings.language');
|
|
114
|
+
expect(select).toBeInTheDocument();
|
|
115
|
+
|
|
116
|
+
expect(screen.getByText('English')).toBeInTheDocument();
|
|
117
|
+
expect(screen.getByText('Deutsch')).toBeInTheDocument();
|
|
118
|
+
expect(screen.getByText('Français')).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('shows current locale as selected', () => {
|
|
122
|
+
renderWithProviders(<SettingsPanel {...defaultProps} locale="de" />);
|
|
123
|
+
const select = screen.getByLabelText('Settings.language') as HTMLSelectElement;
|
|
124
|
+
expect(select.value).toBe('de');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('emits settings:locale-changed on language change', () => {
|
|
128
|
+
const handler = vi.fn();
|
|
129
|
+
const { eventBus } = renderWithProviders(
|
|
130
|
+
<SettingsPanel {...defaultProps} />,
|
|
131
|
+
{ returnEventBus: true }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const sub = eventBus!.get('settings:locale-changed').subscribe(handler);
|
|
135
|
+
fireEvent.change(screen.getByLabelText('Settings.language'), {
|
|
136
|
+
target: { value: 'fr' },
|
|
137
|
+
});
|
|
138
|
+
expect(handler).toHaveBeenCalledWith({ locale: 'fr' });
|
|
139
|
+
sub.unsubscribe();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('disables select when locale change is pending', () => {
|
|
143
|
+
renderWithProviders(
|
|
144
|
+
<SettingsPanel {...defaultProps} isPendingLocaleChange={true} />
|
|
145
|
+
);
|
|
146
|
+
const select = screen.getByLabelText('Settings.language');
|
|
147
|
+
expect(select).toBeDisabled();
|
|
148
|
+
expect(select).toHaveAttribute('aria-busy', 'true');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('shows loading message when locale change is pending', () => {
|
|
152
|
+
renderWithProviders(
|
|
153
|
+
<SettingsPanel {...defaultProps} isPendingLocaleChange={true} />
|
|
154
|
+
);
|
|
155
|
+
expect(screen.getByText('Settings.languageChanging')).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('hover delay slider', () => {
|
|
160
|
+
it('renders hover delay slider', () => {
|
|
161
|
+
renderWithProviders(<SettingsPanel {...defaultProps} />);
|
|
162
|
+
const slider = screen.getByLabelText('Settings.hoverDelay');
|
|
163
|
+
expect(slider).toBeInTheDocument();
|
|
164
|
+
expect(slider).toHaveAttribute('type', 'range');
|
|
165
|
+
expect(slider).toHaveAttribute('min', '0');
|
|
166
|
+
expect(slider).toHaveAttribute('max', '500');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('shows current hover delay value', () => {
|
|
170
|
+
renderWithProviders(<SettingsPanel {...defaultProps} hoverDelayMs={200} />);
|
|
171
|
+
const slider = screen.getByLabelText('Settings.hoverDelay') as HTMLInputElement;
|
|
172
|
+
expect(slider.value).toBe('200');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('emits settings:hover-delay-changed on slider change', () => {
|
|
176
|
+
const handler = vi.fn();
|
|
177
|
+
const { eventBus } = renderWithProviders(
|
|
178
|
+
<SettingsPanel {...defaultProps} />,
|
|
179
|
+
{ returnEventBus: true }
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const sub = eventBus!.get('settings:hover-delay-changed').subscribe(handler);
|
|
183
|
+
fireEvent.change(screen.getByLabelText('Settings.hoverDelay'), {
|
|
184
|
+
target: { value: '300' },
|
|
185
|
+
});
|
|
186
|
+
expect(handler).toHaveBeenCalledWith({ hoverDelayMs: 300 });
|
|
187
|
+
sub.unsubscribe();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|