@semiont/react-ui 0.2.35 → 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 (34) 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 +3 -3
  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/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +2 -0
  21. package/src/components/resource/AnnotateView.tsx +27 -153
  22. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
  23. package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
  24. package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
  25. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
  26. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
  27. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
  28. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
  29. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
  30. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
  31. package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
  32. package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
  33. package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
  34. 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
+ });