@semiont/react-ui 0.2.33-build.78 → 0.2.33-build.79

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 (147) hide show
  1. package/dist/{ar-RNNSPLQB.mjs → ar-EMHEHPCJ.mjs} +2 -1
  2. package/dist/ar-EMHEHPCJ.mjs.map +1 -0
  3. package/dist/{bn-S2CDL7EC.mjs → bn-OVCI4F6X.mjs} +2 -1
  4. package/dist/bn-OVCI4F6X.mjs.map +1 -0
  5. package/dist/{chunk-35LLVRFK.mjs → chunk-JZIO2A3B.mjs} +31 -31
  6. package/dist/{chunk-UDX2Q35T.mjs → chunk-LIHZTECW.mjs} +2 -1
  7. package/dist/chunk-LIHZTECW.mjs.map +1 -0
  8. package/dist/{cs-RSV675WU.mjs → cs-FAN66Q2F.mjs} +2 -1
  9. package/dist/cs-FAN66Q2F.mjs.map +1 -0
  10. package/dist/{da-CHXNPWJC.mjs → da-YBBIHI2O.mjs} +2 -1
  11. package/dist/da-YBBIHI2O.mjs.map +1 -0
  12. package/dist/{de-KPEZ53D4.mjs → de-MAYU33LB.mjs} +2 -1
  13. package/dist/de-MAYU33LB.mjs.map +1 -0
  14. package/dist/{el-MW2BME5T.mjs → el-MKGSWN4O.mjs} +2 -1
  15. package/dist/el-MKGSWN4O.mjs.map +1 -0
  16. package/dist/{en-EVMIX24Y.mjs → en-DDLIXJCU.mjs} +2 -2
  17. package/dist/{es-HQ24NYS3.mjs → es-52LHUWJD.mjs} +2 -1
  18. package/dist/es-52LHUWJD.mjs.map +1 -0
  19. package/dist/{fa-W34LRLHG.mjs → fa-FJICRANB.mjs} +2 -1
  20. package/dist/fa-FJICRANB.mjs.map +1 -0
  21. package/dist/{fi-3U44IGOA.mjs → fi-O455XFCR.mjs} +2 -1
  22. package/dist/fi-O455XFCR.mjs.map +1 -0
  23. package/dist/{fr-N7DKX6NN.mjs → fr-TXIXHOOE.mjs} +2 -1
  24. package/dist/fr-TXIXHOOE.mjs.map +1 -0
  25. package/dist/{he-CS4WRXN3.mjs → he-JBSOX5IN.mjs} +2 -1
  26. package/dist/he-JBSOX5IN.mjs.map +1 -0
  27. package/dist/{hi-GJDY46KA.mjs → hi-KGHI3XVT.mjs} +2 -1
  28. package/dist/hi-KGHI3XVT.mjs.map +1 -0
  29. package/dist/{id-WAEZJK2Y.mjs → id-5OCPPZLO.mjs} +2 -1
  30. package/dist/id-5OCPPZLO.mjs.map +1 -0
  31. package/dist/index.d.mts +102 -106
  32. package/dist/index.mjs +1814 -1450
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/{it-VDNDMZPU.mjs → it-PNBBZSM2.mjs} +2 -1
  35. package/dist/it-PNBBZSM2.mjs.map +1 -0
  36. package/dist/{ja-5PEH56J5.mjs → ja-LDD7R3TJ.mjs} +2 -1
  37. package/dist/ja-LDD7R3TJ.mjs.map +1 -0
  38. package/dist/{ko-JYPL3WVA.mjs → ko-F47ZDEY3.mjs} +2 -1
  39. package/dist/ko-F47ZDEY3.mjs.map +1 -0
  40. package/dist/{ms-5PZVW76T.mjs → ms-Z7LMXJWL.mjs} +2 -1
  41. package/dist/ms-Z7LMXJWL.mjs.map +1 -0
  42. package/dist/{nl-YXES36KM.mjs → nl-6SJFBPJ3.mjs} +2 -1
  43. package/dist/nl-6SJFBPJ3.mjs.map +1 -0
  44. package/dist/{no-XRA2UCQD.mjs → no-YXPBPSGF.mjs} +2 -1
  45. package/dist/no-YXPBPSGF.mjs.map +1 -0
  46. package/dist/{pl-WH6LJA5G.mjs → pl-P4AZ2QME.mjs} +2 -1
  47. package/dist/pl-P4AZ2QME.mjs.map +1 -0
  48. package/dist/{pt-7GAG57BM.mjs → pt-LHWUS6U6.mjs} +2 -1
  49. package/dist/pt-LHWUS6U6.mjs.map +1 -0
  50. package/dist/{ro-BTDDRB7N.mjs → ro-EA5J2ZON.mjs} +2 -1
  51. package/dist/ro-EA5J2ZON.mjs.map +1 -0
  52. package/dist/{sv-7V5C2IT4.mjs → sv-DATBS3UQ.mjs} +2 -1
  53. package/dist/sv-DATBS3UQ.mjs.map +1 -0
  54. package/dist/test-utils.mjs +2 -2
  55. package/dist/{th-LPKYLBX5.mjs → th-WTFJRWPT.mjs} +2 -1
  56. package/dist/th-WTFJRWPT.mjs.map +1 -0
  57. package/dist/{tr-DU4RQL4M.mjs → tr-IKO3RXOX.mjs} +2 -1
  58. package/dist/tr-IKO3RXOX.mjs.map +1 -0
  59. package/dist/{uk-36UHTDDI.mjs → uk-CF6CTTRK.mjs} +2 -1
  60. package/dist/uk-CF6CTTRK.mjs.map +1 -0
  61. package/dist/{vi-GDHOUZDH.mjs → vi-AJLTXPZQ.mjs} +2 -1
  62. package/dist/vi-AJLTXPZQ.mjs.map +1 -0
  63. package/dist/{zh-TYUID4XZ.mjs → zh-U3ORHHYH.mjs} +2 -1
  64. package/dist/zh-U3ORHHYH.mjs.map +1 -0
  65. package/package.json +6 -2
  66. package/src/components/resource/AnnotateView.tsx +0 -4
  67. package/src/components/resource/AnnotationHistory.tsx +12 -13
  68. package/src/components/resource/BrowseView.tsx +8 -16
  69. package/src/components/resource/HistoryEvent.tsx +3 -4
  70. package/src/components/resource/ResourceViewer.tsx +174 -201
  71. package/src/components/resource/event-formatting.ts +316 -0
  72. package/src/components/resource/panels/AssessmentPanel.tsx +37 -9
  73. package/src/components/resource/panels/CollaborationPanel.tsx +20 -13
  74. package/src/components/resource/panels/CommentsPanel.tsx +38 -9
  75. package/src/components/resource/panels/ReferencesPanel.tsx +39 -14
  76. package/src/components/resource/panels/StatisticsPanel.tsx +9 -19
  77. package/src/components/resource/panels/TaggingPanel.tsx +27 -0
  78. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +28 -21
  79. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +547 -0
  80. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +10 -0
  81. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +10 -0
  82. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +564 -0
  83. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +8 -15
  84. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +13 -6
  85. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +147 -78
  86. package/src/styles/motivations/motivation-assessment.css +28 -0
  87. package/src/styles/patterns/panel-helpers.css +26 -0
  88. package/translations/ar.json +1 -0
  89. package/translations/bn.json +1 -0
  90. package/translations/cs.json +1 -0
  91. package/translations/da.json +1 -0
  92. package/translations/de.json +1 -0
  93. package/translations/el.json +1 -0
  94. package/translations/en.json +1 -0
  95. package/translations/es.json +1 -0
  96. package/translations/fa.json +1 -0
  97. package/translations/fi.json +1 -0
  98. package/translations/fr.json +1 -0
  99. package/translations/he.json +1 -0
  100. package/translations/hi.json +1 -0
  101. package/translations/id.json +1 -0
  102. package/translations/it.json +1 -0
  103. package/translations/ja.json +1 -0
  104. package/translations/ko.json +1 -0
  105. package/translations/ms.json +1 -0
  106. package/translations/nl.json +1 -0
  107. package/translations/no.json +1 -0
  108. package/translations/pl.json +1 -0
  109. package/translations/pt.json +1 -0
  110. package/translations/ro.json +1 -0
  111. package/translations/sv.json +1 -0
  112. package/translations/th.json +1 -0
  113. package/translations/tr.json +1 -0
  114. package/translations/uk.json +1 -0
  115. package/translations/vi.json +1 -0
  116. package/translations/zh.json +1 -0
  117. package/dist/ar-RNNSPLQB.mjs.map +0 -1
  118. package/dist/bn-S2CDL7EC.mjs.map +0 -1
  119. package/dist/chunk-UDX2Q35T.mjs.map +0 -1
  120. package/dist/cs-RSV675WU.mjs.map +0 -1
  121. package/dist/da-CHXNPWJC.mjs.map +0 -1
  122. package/dist/de-KPEZ53D4.mjs.map +0 -1
  123. package/dist/el-MW2BME5T.mjs.map +0 -1
  124. package/dist/es-HQ24NYS3.mjs.map +0 -1
  125. package/dist/fa-W34LRLHG.mjs.map +0 -1
  126. package/dist/fi-3U44IGOA.mjs.map +0 -1
  127. package/dist/fr-N7DKX6NN.mjs.map +0 -1
  128. package/dist/he-CS4WRXN3.mjs.map +0 -1
  129. package/dist/hi-GJDY46KA.mjs.map +0 -1
  130. package/dist/id-WAEZJK2Y.mjs.map +0 -1
  131. package/dist/it-VDNDMZPU.mjs.map +0 -1
  132. package/dist/ja-5PEH56J5.mjs.map +0 -1
  133. package/dist/ko-JYPL3WVA.mjs.map +0 -1
  134. package/dist/ms-5PZVW76T.mjs.map +0 -1
  135. package/dist/nl-YXES36KM.mjs.map +0 -1
  136. package/dist/no-XRA2UCQD.mjs.map +0 -1
  137. package/dist/pl-WH6LJA5G.mjs.map +0 -1
  138. package/dist/pt-7GAG57BM.mjs.map +0 -1
  139. package/dist/ro-BTDDRB7N.mjs.map +0 -1
  140. package/dist/sv-7V5C2IT4.mjs.map +0 -1
  141. package/dist/th-LPKYLBX5.mjs.map +0 -1
  142. package/dist/tr-DU4RQL4M.mjs.map +0 -1
  143. package/dist/uk-36UHTDDI.mjs.map +0 -1
  144. package/dist/vi-GDHOUZDH.mjs.map +0 -1
  145. package/dist/zh-TYUID4XZ.mjs.map +0 -1
  146. /package/dist/{chunk-35LLVRFK.mjs.map → chunk-JZIO2A3B.mjs.map} +0 -0
  147. /package/dist/{en-EVMIX24Y.mjs.map → en-DDLIXJCU.mjs.map} +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
+ import { useMakeMeaningEvents } from '../../../contexts/MakeMeaningEventBusContext';
5
6
  import type { components, Selector } from '@semiont/api-client';
6
7
  import { TagEntry } from './TagEntry';
7
8
  import { useAnnotationPanel } from '../../../hooks/useAnnotationPanel';
@@ -71,6 +72,7 @@ export function TaggingPanel({
71
72
  pendingAnnotation
72
73
  }: TaggingPanelProps) {
73
74
  const t = useTranslations('TaggingPanel');
75
+ const eventBus = useMakeMeaningEvents();
74
76
  const [selectedSchemaId, setSelectedSchemaId] = useState<string>('legal-irac');
75
77
  const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
76
78
 
@@ -125,6 +127,20 @@ export function TaggingPanel({
125
127
  }
126
128
  };
127
129
 
130
+ // Escape key handler for cancelling pending annotation
131
+ useEffect(() => {
132
+ if (!pendingAnnotation) return;
133
+
134
+ const handleEscape = (e: KeyboardEvent) => {
135
+ if (e.key === 'Escape') {
136
+ eventBus.emit('ui:annotation:cancel-pending');
137
+ }
138
+ };
139
+
140
+ document.addEventListener('keydown', handleEscape);
141
+ return () => document.removeEventListener('keydown', handleEscape);
142
+ }, [pendingAnnotation, eventBus]);
143
+
128
144
  // Color schemes are now handled via CSS data attributes
129
145
 
130
146
  return (
@@ -191,6 +207,17 @@ export function TaggingPanel({
191
207
  </select>
192
208
  </div>
193
209
  )}
210
+
211
+ {/* Cancel button */}
212
+ <div className="semiont-annotation-prompt__footer">
213
+ <button
214
+ onClick={() => eventBus.emit('ui:annotation:cancel-pending')}
215
+ className="semiont-button semiont-button--secondary"
216
+ data-type="tag"
217
+ >
218
+ {t('cancel')}
219
+ </button>
220
+ </div>
194
221
  </div>
195
222
  )}
196
223
 
@@ -6,6 +6,7 @@ import type { components, Selector } from '@semiont/api-client';
6
6
  import type { RouteBuilder, LinkComponentProps } from '../../../contexts/RoutingContext';
7
7
  import type { Annotator } from '../../../lib/annotation-registry';
8
8
  import { createDetectionHandler } from '../../../lib/annotation-registry';
9
+ import { supportsDetection } from '../../../lib/resource-utils';
9
10
  import { StatisticsPanel } from './StatisticsPanel';
10
11
  import { HighlightPanel } from './HighlightPanel';
11
12
  import { ReferencesPanel } from './ReferencesPanel';
@@ -108,27 +109,25 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
108
109
  const t = useTranslations('UnifiedAnnotationsPanel');
109
110
 
110
111
  // Group annotations by type using annotators
111
- const grouped = React.useMemo(() => {
112
- const groups: Record<string, Annotation[]> = {
113
- highlight: [],
114
- comment: [],
115
- assessment: [],
116
- reference: [],
117
- tag: []
118
- };
119
-
120
- for (const ann of props.annotations) {
121
- const annotator = Object.values(props.annotators).find(a => a.matchesAnnotation(ann));
122
- if (annotator) {
123
- if (!groups[annotator.internalType]) {
124
- groups[annotator.internalType] = [];
125
- }
126
- groups[annotator.internalType].push(ann);
112
+ const groups: Record<string, Annotation[]> = {
113
+ highlight: [],
114
+ comment: [],
115
+ assessment: [],
116
+ reference: [],
117
+ tag: []
118
+ };
119
+
120
+ for (const ann of props.annotations) {
121
+ const annotator = Object.values(props.annotators).find(a => a.matchesAnnotation(ann));
122
+ if (annotator) {
123
+ if (!groups[annotator.internalType]) {
124
+ groups[annotator.internalType] = [];
127
125
  }
126
+ groups[annotator.internalType].push(ann);
128
127
  }
128
+ }
129
129
 
130
- return groups;
131
- }, [props.annotations, props.annotators]);
130
+ const grouped = groups;
132
131
 
133
132
  // Load tab from localStorage (per-resource)
134
133
  const [activeTab, setActiveTab] = useState<TabKey>(() => {
@@ -270,8 +269,17 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
270
269
  const detectionProgress = isDetecting ? props.detectionProgress : null;
271
270
 
272
271
  // Common props for all annotation panels
273
- // Create detection handler on-demand if detection is supported and context is provided
274
- const onDetect = (annotator.detection && props.detectionContext)
272
+ // Create detection handler on-demand if:
273
+ // 1. Annotator supports detection (has detection config)
274
+ // 2. Detection context is provided (API client, state handlers)
275
+ // 3. API client is available (not undefined/null)
276
+ // 4. Resource supports detection (is a text/* media type)
277
+ const onDetect = (
278
+ annotator.detection &&
279
+ props.detectionContext &&
280
+ props.detectionContext.client &&
281
+ supportsDetection(props.mediaType)
282
+ )
275
283
  ? createDetectionHandler(annotator, props.detectionContext)
276
284
  : undefined;
277
285
 
@@ -323,7 +331,6 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
323
331
  onCreateDocument={props.onCreateDocument}
324
332
  onSearchDocuments={props.onSearchDocuments}
325
333
  generatingReferenceId={props.generatingReferenceId}
326
- mediaType={props.mediaType}
327
334
  referencedBy={props.referencedBy}
328
335
  referencedByLoading={props.referencedByLoading}
329
336
  Link={props.Link}
@@ -0,0 +1,547 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import type { MockedFunction } from 'vitest';
3
+ import React from 'react';
4
+ import { render, screen, fireEvent } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+ import '@testing-library/jest-dom';
7
+ import { AssessmentPanel } from '../AssessmentPanel';
8
+ import type { components } from '@semiont/api-client';
9
+
10
+ type Annotation = components['schemas']['Annotation'];
11
+
12
+ // Mock MakeMeaningEventBusContext
13
+ vi.mock('../../../../contexts/MakeMeaningEventBusContext', () => ({
14
+ useMakeMeaningEvents: vi.fn(() => ({
15
+ emit: vi.fn(),
16
+ on: vi.fn(),
17
+ off: vi.fn(),
18
+ })),
19
+ }));
20
+
21
+ // Mock TranslationContext
22
+ vi.mock('../../../../contexts/TranslationContext', () => ({
23
+ useTranslations: vi.fn(() => (key: string) => {
24
+ const translations: Record<string, string> = {
25
+ title: 'Assessments',
26
+ noAssessments: 'No assessments yet. Select text to add an assessment.',
27
+ assessmentPlaceholder: 'Type your assessment here...',
28
+ save: 'Save',
29
+ cancel: 'Cancel',
30
+ fragmentSelected: 'Fragment selected',
31
+ };
32
+ return translations[key] || key;
33
+ }),
34
+ }));
35
+
36
+ // Mock @semiont/api-client utilities
37
+ vi.mock('@semiont/api-client', async () => {
38
+ const actual = await vi.importActual('@semiont/api-client');
39
+ return {
40
+ ...actual,
41
+ getTextPositionSelector: vi.fn(),
42
+ getTargetSelector: vi.fn(),
43
+ };
44
+ });
45
+
46
+ // Mock AssessmentEntry component to simplify testing
47
+ vi.mock('../AssessmentEntry', () => ({
48
+ AssessmentEntry: ({ assessment, onClick, onAssessmentRef, onAssessmentHover }: any) => (
49
+ <div
50
+ data-testid={`assessment-${assessment.id}`}
51
+ onClick={() => onClick()}
52
+ >
53
+ <button
54
+ onMouseEnter={() => onAssessmentHover?.(assessment.id)}
55
+ onMouseLeave={() => onAssessmentHover?.(null)}
56
+ >
57
+ Hover
58
+ </button>
59
+ <div>{assessment.id}</div>
60
+ </div>
61
+ ),
62
+ }));
63
+
64
+ // Mock DetectSection component
65
+ vi.mock('../DetectSection', () => ({
66
+ DetectSection: ({ annotationType, isDetecting, onDetect }: any) => (
67
+ <div data-testid="detect-section">
68
+ <button onClick={() => onDetect?.('test instructions')}>
69
+ Start Detection
70
+ </button>
71
+ {isDetecting && <div>Detecting...</div>}
72
+ </div>
73
+ ),
74
+ }));
75
+
76
+ import { getTextPositionSelector, getTargetSelector } from '@semiont/api-client';
77
+
78
+ const mockGetTextPositionSelector = getTextPositionSelector as MockedFunction<typeof getTextPositionSelector>;
79
+ const mockGetTargetSelector = getTargetSelector as MockedFunction<typeof getTargetSelector>;
80
+
81
+ // Test data fixtures
82
+ const createMockAssessment = (id: string, start: number, end: number): Annotation => ({
83
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
84
+ id,
85
+ type: 'Annotation',
86
+ motivation: 'assessing',
87
+ creator: {
88
+ name: `user${id}@example.com`,
89
+ },
90
+ created: `2024-01-0${id.slice(-1)}T10:00:00Z`,
91
+ modified: `2024-01-0${id.slice(-1)}T10:00:00Z`,
92
+ target: {
93
+ source: 'resource-1',
94
+ selector: {
95
+ type: 'TextPositionSelector',
96
+ start,
97
+ end,
98
+ },
99
+ },
100
+ body: [
101
+ {
102
+ type: 'TextualBody',
103
+ value: `Assessment ${id}`,
104
+ purpose: 'assessing',
105
+ },
106
+ ],
107
+ });
108
+
109
+ const mockAssessments = {
110
+ empty: [],
111
+ single: [createMockAssessment('1', 0, 10)],
112
+ multiple: [
113
+ createMockAssessment('1', 50, 60), // Middle position
114
+ createMockAssessment('2', 0, 10), // First position
115
+ createMockAssessment('3', 100, 110), // Last position
116
+ ],
117
+ };
118
+
119
+ // Helper to create pending annotation
120
+ const createPendingAnnotation = (exact: string) => ({
121
+ motivation: 'assessing' as const,
122
+ selector: {
123
+ type: 'TextQuoteSelector' as const,
124
+ exact,
125
+ },
126
+ });
127
+
128
+ describe('AssessmentPanel Component', () => {
129
+ const defaultProps = {
130
+ annotations: mockAssessments.empty,
131
+ onAnnotationClick: vi.fn(),
132
+ onCreate: vi.fn(),
133
+ focusedAnnotationId: null,
134
+ pendingAnnotation: null,
135
+ };
136
+
137
+ beforeEach(() => {
138
+ vi.clearAllMocks();
139
+
140
+ // Mock scrollIntoView for jsdom
141
+ Element.prototype.scrollIntoView = vi.fn();
142
+
143
+ // Mock selector functions to return proper position data
144
+ mockGetTargetSelector.mockImplementation((target: any) => target.selector);
145
+ mockGetTextPositionSelector.mockImplementation((selector: any) => {
146
+ if (selector?.type === 'TextPositionSelector') {
147
+ return selector;
148
+ }
149
+ return null;
150
+ });
151
+ });
152
+
153
+ afterEach(() => {
154
+ vi.restoreAllMocks();
155
+ });
156
+
157
+ describe('Rendering', () => {
158
+ it('should render panel header with title and count', () => {
159
+ render(<AssessmentPanel {...defaultProps} annotations={mockAssessments.multiple} />);
160
+
161
+ expect(screen.getByText(/Assessments/)).toBeInTheDocument();
162
+ expect(screen.getByText(/\(3\)/)).toBeInTheDocument();
163
+ });
164
+
165
+ it('should show empty state when no assessments', () => {
166
+ render(<AssessmentPanel {...defaultProps} />);
167
+
168
+ expect(screen.getByText(/No assessments yet/)).toBeInTheDocument();
169
+ });
170
+
171
+ it('should render all assessments', () => {
172
+ render(<AssessmentPanel {...defaultProps} annotations={mockAssessments.multiple} />);
173
+
174
+ expect(screen.getByTestId('assessment-1')).toBeInTheDocument();
175
+ expect(screen.getByTestId('assessment-2')).toBeInTheDocument();
176
+ expect(screen.getByTestId('assessment-3')).toBeInTheDocument();
177
+ });
178
+
179
+ it('should have proper panel structure', () => {
180
+ const { container } = render(<AssessmentPanel {...defaultProps} />);
181
+
182
+ const panel = container.firstChild as HTMLElement;
183
+ expect(panel).toHaveClass('semiont-panel');
184
+ });
185
+ });
186
+
187
+ describe('Assessment Sorting', () => {
188
+ it('should sort assessments by position in resource', () => {
189
+ render(<AssessmentPanel {...defaultProps} annotations={mockAssessments.multiple} />);
190
+
191
+ const assessments = screen.getAllByTestId(/assessment-/);
192
+
193
+ // Should be sorted by start position: assessment-2 (0), assessment-1 (50), assessment-3 (100)
194
+ expect(assessments[0]).toHaveAttribute('data-testid', 'assessment-2');
195
+ expect(assessments[1]).toHaveAttribute('data-testid', 'assessment-1');
196
+ expect(assessments[2]).toHaveAttribute('data-testid', 'assessment-3');
197
+ });
198
+
199
+ it('should handle assessments without valid selectors', () => {
200
+ mockGetTextPositionSelector.mockReturnValue(null);
201
+
202
+ expect(() => {
203
+ render(<AssessmentPanel {...defaultProps} annotations={mockAssessments.multiple} />);
204
+ }).not.toThrow();
205
+ });
206
+ });
207
+
208
+ describe('New Assessment Creation', () => {
209
+ it('should not show new assessment input by default', () => {
210
+ render(<AssessmentPanel {...defaultProps} />);
211
+
212
+ expect(screen.queryByPlaceholderText(/Type your assessment here/)).not.toBeInTheDocument();
213
+ });
214
+
215
+ it('should show new assessment input when pendingAnnotation exists', () => {
216
+ const pendingAnnotation = createPendingAnnotation('Selected text');
217
+
218
+ render(
219
+ <AssessmentPanel
220
+ {...defaultProps}
221
+ pendingAnnotation={pendingAnnotation}
222
+ />
223
+ );
224
+
225
+ expect(screen.getByPlaceholderText(/Type your assessment here/)).toBeInTheDocument();
226
+ });
227
+
228
+ it('should display quoted selected text in new assessment area', () => {
229
+ const pendingAnnotation = createPendingAnnotation('Selected text for assessment');
230
+
231
+ render(
232
+ <AssessmentPanel
233
+ {...defaultProps}
234
+ pendingAnnotation={pendingAnnotation}
235
+ />
236
+ );
237
+
238
+ expect(screen.getByText(/"Selected text for assessment"/)).toBeInTheDocument();
239
+ });
240
+
241
+ it('should truncate long selected text at 100 characters', () => {
242
+ const longText = 'A'.repeat(150);
243
+ const pendingAnnotation = createPendingAnnotation(longText);
244
+
245
+ render(
246
+ <AssessmentPanel
247
+ {...defaultProps}
248
+ pendingAnnotation={pendingAnnotation}
249
+ />
250
+ );
251
+
252
+ expect(screen.getByText(new RegExp(`"${'A'.repeat(100)}`))).toBeInTheDocument();
253
+ expect(screen.getByText(/\.\.\./)).toBeInTheDocument();
254
+ });
255
+
256
+ it('should allow typing in new assessment textarea', async () => {
257
+ const pendingAnnotation = createPendingAnnotation('Selected text');
258
+
259
+ render(
260
+ <AssessmentPanel
261
+ {...defaultProps}
262
+ pendingAnnotation={pendingAnnotation}
263
+ />
264
+ );
265
+
266
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
267
+ await userEvent.type(textarea, 'My assessment');
268
+
269
+ expect(textarea).toHaveValue('My assessment');
270
+ });
271
+
272
+ it('should show character count', async () => {
273
+ const pendingAnnotation = createPendingAnnotation('Selected text');
274
+
275
+ render(
276
+ <AssessmentPanel
277
+ {...defaultProps}
278
+ pendingAnnotation={pendingAnnotation}
279
+ />
280
+ );
281
+
282
+ expect(screen.getByText('0/2000')).toBeInTheDocument();
283
+
284
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
285
+ await userEvent.type(textarea, 'Test');
286
+
287
+ expect(screen.getByText('4/2000')).toBeInTheDocument();
288
+ });
289
+
290
+ it('should enforce maxLength of 2000 characters', () => {
291
+ const pendingAnnotation = createPendingAnnotation('Selected text');
292
+
293
+ render(
294
+ <AssessmentPanel
295
+ {...defaultProps}
296
+ pendingAnnotation={pendingAnnotation}
297
+ />
298
+ );
299
+
300
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/) as HTMLTextAreaElement;
301
+ expect(textarea).toHaveAttribute('maxLength', '2000');
302
+ });
303
+
304
+ it('should auto-focus new assessment textarea', () => {
305
+ const pendingAnnotation = createPendingAnnotation('Selected text');
306
+
307
+ render(
308
+ <AssessmentPanel
309
+ {...defaultProps}
310
+ pendingAnnotation={pendingAnnotation}
311
+ />
312
+ );
313
+
314
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
315
+ expect(textarea).toHaveFocus();
316
+ });
317
+
318
+ it('should call onCreate when save is clicked', async () => {
319
+ const onCreate = vi.fn();
320
+ const pendingAnnotation = createPendingAnnotation('Selected text');
321
+
322
+ render(
323
+ <AssessmentPanel
324
+ {...defaultProps}
325
+ pendingAnnotation={pendingAnnotation}
326
+ onCreate={onCreate}
327
+ />
328
+ );
329
+
330
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
331
+ await userEvent.type(textarea, 'My assessment');
332
+
333
+ const saveButton = screen.getByText('Save');
334
+ await userEvent.click(saveButton);
335
+
336
+ expect(onCreate).toHaveBeenCalledWith('My assessment');
337
+ });
338
+
339
+ it('should clear textarea after successful save', async () => {
340
+ const pendingAnnotation = createPendingAnnotation('Selected text');
341
+
342
+ render(
343
+ <AssessmentPanel
344
+ {...defaultProps}
345
+ pendingAnnotation={pendingAnnotation}
346
+ />
347
+ );
348
+
349
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
350
+ await userEvent.type(textarea, 'My assessment');
351
+ await userEvent.click(screen.getByText('Save'));
352
+
353
+ expect(textarea).toHaveValue('');
354
+ });
355
+
356
+ it('should allow saving with empty text (assessment text is optional)', async () => {
357
+ const onCreate = vi.fn();
358
+ const pendingAnnotation = createPendingAnnotation('Selected text');
359
+
360
+ render(
361
+ <AssessmentPanel
362
+ {...defaultProps}
363
+ pendingAnnotation={pendingAnnotation}
364
+ onCreate={onCreate}
365
+ />
366
+ );
367
+
368
+ const saveButton = screen.getByText('Save');
369
+ await userEvent.click(saveButton);
370
+
371
+ // Should NOT call onCreate with empty text (handleSaveNewAssessment checks trim())
372
+ expect(onCreate).not.toHaveBeenCalled();
373
+ });
374
+
375
+ it('should have proper styling for new assessment area', () => {
376
+ const pendingAnnotation = createPendingAnnotation('Selected text');
377
+
378
+ const { container } = render(
379
+ <AssessmentPanel
380
+ {...defaultProps}
381
+ pendingAnnotation={pendingAnnotation}
382
+ />
383
+ );
384
+
385
+ const newAssessmentArea = container.querySelector('.semiont-annotation-prompt');
386
+ expect(newAssessmentArea).toBeInTheDocument();
387
+ expect(newAssessmentArea).toHaveAttribute('data-type', 'assessment');
388
+ });
389
+ });
390
+
391
+ describe('Assessment Interactions', () => {
392
+ it('should call onAnnotationClick when assessment is clicked', () => {
393
+ const onAnnotationClick = vi.fn();
394
+ render(
395
+ <AssessmentPanel
396
+ {...defaultProps}
397
+ annotations={mockAssessments.single}
398
+ onAnnotationClick={onAnnotationClick}
399
+ />
400
+ );
401
+
402
+ const assessment = screen.getByTestId('assessment-1');
403
+ fireEvent.click(assessment);
404
+
405
+ expect(onAnnotationClick).toHaveBeenCalledWith(mockAssessments.single[0]);
406
+ });
407
+ });
408
+
409
+ describe('Assessment Hover Behavior', () => {
410
+ it('should call onAnnotationHover when provided', () => {
411
+ const onAnnotationHover = vi.fn();
412
+ render(
413
+ <AssessmentPanel
414
+ {...defaultProps}
415
+ annotations={mockAssessments.single}
416
+ onAnnotationHover={onAnnotationHover}
417
+ />
418
+ );
419
+
420
+ const hoverButton = screen.getByText('Hover');
421
+ fireEvent.mouseEnter(hoverButton);
422
+
423
+ expect(onAnnotationHover).toHaveBeenCalledWith('1');
424
+ });
425
+
426
+ it('should not error when onAnnotationHover is not provided', () => {
427
+ expect(() => {
428
+ render(
429
+ <AssessmentPanel
430
+ {...defaultProps}
431
+ annotations={mockAssessments.single}
432
+ />
433
+ );
434
+ }).not.toThrow();
435
+ });
436
+ });
437
+
438
+ describe('Detection Section', () => {
439
+ it('should render DetectSection when onDetect is provided and annotateMode is true', () => {
440
+ render(
441
+ <AssessmentPanel
442
+ {...defaultProps}
443
+ onDetect={vi.fn()}
444
+ annotateMode={true}
445
+ />
446
+ );
447
+
448
+ expect(screen.getByTestId('detect-section')).toBeInTheDocument();
449
+ });
450
+
451
+ it('should not render DetectSection when onDetect is not provided', () => {
452
+ render(
453
+ <AssessmentPanel
454
+ {...defaultProps}
455
+ annotateMode={true}
456
+ />
457
+ );
458
+
459
+ expect(screen.queryByTestId('detect-section')).not.toBeInTheDocument();
460
+ });
461
+
462
+ it('should not render DetectSection when annotateMode is false', () => {
463
+ render(
464
+ <AssessmentPanel
465
+ {...defaultProps}
466
+ onDetect={vi.fn()}
467
+ annotateMode={false}
468
+ />
469
+ );
470
+
471
+ expect(screen.queryByTestId('detect-section')).not.toBeInTheDocument();
472
+ });
473
+
474
+ it('should call onDetect when detection is started', async () => {
475
+ const onDetect = vi.fn();
476
+ render(
477
+ <AssessmentPanel
478
+ {...defaultProps}
479
+ onDetect={onDetect}
480
+ annotateMode={true}
481
+ />
482
+ );
483
+
484
+ const detectButton = screen.getByText('Start Detection');
485
+ await userEvent.click(detectButton);
486
+
487
+ expect(onDetect).toHaveBeenCalledWith('test instructions');
488
+ });
489
+ });
490
+
491
+ describe('Cancel Functionality', () => {
492
+ it('should show Cancel button when pendingAnnotation exists', () => {
493
+ const pendingAnnotation = createPendingAnnotation('Selected text');
494
+
495
+ render(
496
+ <AssessmentPanel
497
+ {...defaultProps}
498
+ pendingAnnotation={pendingAnnotation}
499
+ />
500
+ );
501
+
502
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
503
+ });
504
+
505
+ it('should clear textarea when Cancel button is clicked', async () => {
506
+ const pendingAnnotation = createPendingAnnotation('Selected text');
507
+
508
+ render(
509
+ <AssessmentPanel
510
+ {...defaultProps}
511
+ pendingAnnotation={pendingAnnotation}
512
+ />
513
+ );
514
+
515
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
516
+ await userEvent.type(textarea, 'My assessment');
517
+
518
+ const cancelButton = screen.getByText('Cancel');
519
+ await userEvent.click(cancelButton);
520
+
521
+ expect(textarea).toHaveValue('');
522
+ });
523
+ });
524
+
525
+ describe('Accessibility', () => {
526
+ it('should have proper heading structure', () => {
527
+ render(<AssessmentPanel {...defaultProps} />);
528
+
529
+ const heading = screen.getByText(/Assessments/);
530
+ expect(heading).toHaveClass('semiont-panel-header__text');
531
+ });
532
+
533
+ it('should have proper textarea attributes for new assessments', () => {
534
+ const pendingAnnotation = createPendingAnnotation('Selected text');
535
+
536
+ render(
537
+ <AssessmentPanel
538
+ {...defaultProps}
539
+ pendingAnnotation={pendingAnnotation}
540
+ />
541
+ );
542
+
543
+ const textarea = screen.getByPlaceholderText(/Type your assessment here/);
544
+ expect(textarea).toHaveAttribute('rows', '3');
545
+ });
546
+ });
547
+ });
@@ -9,6 +9,15 @@ import type { components } from '@semiont/api-client';
9
9
 
10
10
  type Annotation = components['schemas']['Annotation'];
11
11
 
12
+ // Mock MakeMeaningEventBusContext
13
+ vi.mock('../../../../contexts/MakeMeaningEventBusContext', () => ({
14
+ useMakeMeaningEvents: vi.fn(() => ({
15
+ emit: vi.fn(),
16
+ on: vi.fn(),
17
+ off: vi.fn(),
18
+ })),
19
+ }));
20
+
12
21
  // Mock TranslationContext
13
22
  vi.mock('../../../../contexts/TranslationContext', () => ({
14
23
  useTranslations: vi.fn(() => (key: string) => {
@@ -17,6 +26,7 @@ vi.mock('../../../../contexts/TranslationContext', () => ({
17
26
  noComments: 'No comments yet. Select text to add a comment.',
18
27
  commentPlaceholder: 'Add your comment...',
19
28
  save: 'Save',
29
+ cancel: 'Cancel',
20
30
  };
21
31
  return translations[key] || key;
22
32
  }),