|
| 1 | +/** @file Unit tests for components/AnalysisStore.tsx. */ |
| 2 | +/// <reference types="jest" /> |
| 3 | +/// <reference types="@testing-library/jest-dom" /> |
| 4 | + |
| 5 | +import { render, screen } from '@testing-library/react'; |
| 6 | +import userEvent from '@testing-library/user-event'; |
| 7 | +import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; |
| 8 | +import { |
| 9 | + AnalysisStoreProvider, |
| 10 | + useAnalysis, |
| 11 | + useGloss, |
| 12 | + useGlossDispatch, |
| 13 | +} from '../../components/AnalysisStore'; |
| 14 | + |
| 15 | +// --------------------------------------------------------------------------- |
| 16 | +// Helpers |
| 17 | +// --------------------------------------------------------------------------- |
| 18 | + |
| 19 | +/** |
| 20 | + * Builds a minimal `TextAnalysis` with a single approved `TokenAnalysis` for the given token. |
| 21 | + * |
| 22 | + * @param tokenRef - Token reference string. |
| 23 | + * @param gloss - Gloss value for the `'en'` language key. |
| 24 | + * @param surfaceText - Surface text of the token. |
| 25 | + * @returns A `TextAnalysis` seeded with one approved token analysis. |
| 26 | + */ |
| 27 | +function makeAnalysisWithGloss( |
| 28 | + tokenRef: string, |
| 29 | + gloss: string, |
| 30 | + surfaceText = 'word', |
| 31 | +): TextAnalysis { |
| 32 | + const ta: TokenAnalysis = { |
| 33 | + id: `${tokenRef}-analysis`, |
| 34 | + surfaceText, |
| 35 | + gloss: { en: gloss }, |
| 36 | + }; |
| 37 | + const link: TokenAnalysisLink = { |
| 38 | + analysisId: ta.id, |
| 39 | + status: 'approved', |
| 40 | + token: { tokenRef, surfaceText }, |
| 41 | + }; |
| 42 | + return { |
| 43 | + segmentAnalyses: [], |
| 44 | + segmentAnalysisLinks: [], |
| 45 | + tokenAnalyses: [ta], |
| 46 | + tokenAnalysisLinks: [link], |
| 47 | + phraseAnalyses: [], |
| 48 | + phraseAnalysisLinks: [], |
| 49 | + }; |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Renders a component that displays the gloss for a single token, used to assert on `useGloss`. |
| 54 | + * |
| 55 | + * @param tokenRef - Token ref to subscribe to. |
| 56 | + * @returns JSX element suitable for passing to `render`. |
| 57 | + */ |
| 58 | +function GlossReader({ tokenRef }: Readonly<{ tokenRef: string }>) { |
| 59 | + const gloss = useGloss(tokenRef); |
| 60 | + return <span data-testid="gloss">{gloss}</span>; |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Renders a component that displays the full analysis as JSON, used to assert on `useAnalysis`. |
| 65 | + * |
| 66 | + * @returns JSX element suitable for passing to `render`. |
| 67 | + */ |
| 68 | +function AnalysisReader() { |
| 69 | + const analysis = useAnalysis(); |
| 70 | + return <span data-testid="analysis">{JSON.stringify(analysis)}</span>; |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Renders a component that calls `useGlossDispatch` without a provider, used to assert the hook |
| 75 | + * throws outside an {@link AnalysisStoreProvider}. |
| 76 | + * |
| 77 | + * @returns Nothing — only mounted to trigger the throw. |
| 78 | + */ |
| 79 | +function DispatchUser() { |
| 80 | + useGlossDispatch(); |
| 81 | + return undefined; |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * Renders a button that calls `useGlossDispatch` to write a gloss, used to test dispatch. |
| 86 | + * |
| 87 | + * @param props.tokenRef - Token ref to write. |
| 88 | + * @param props.surfaceText - Surface text of the token. |
| 89 | + * @param props.value - Gloss value to write. |
| 90 | + * @returns JSX element suitable for passing to `render`. |
| 91 | + */ |
| 92 | +function GlossWriter({ |
| 93 | + tokenRef, |
| 94 | + surfaceText, |
| 95 | + value, |
| 96 | +}: Readonly<{ tokenRef: string; surfaceText: string; value: string }>) { |
| 97 | + const dispatch = useGlossDispatch(); |
| 98 | + return ( |
| 99 | + <button onClick={() => dispatch(tokenRef, surfaceText, value)} type="button"> |
| 100 | + write |
| 101 | + </button> |
| 102 | + ); |
| 103 | +} |
| 104 | + |
| 105 | +// --------------------------------------------------------------------------- |
| 106 | +// Tests |
| 107 | +// --------------------------------------------------------------------------- |
| 108 | + |
| 109 | +describe('useGloss', () => { |
| 110 | + it('returns an empty string for an unknown token', () => { |
| 111 | + render( |
| 112 | + <AnalysisStoreProvider> |
| 113 | + <GlossReader tokenRef="tok-1" /> |
| 114 | + </AnalysisStoreProvider>, |
| 115 | + ); |
| 116 | + expect(screen.getByTestId('gloss')).toHaveTextContent(''); |
| 117 | + }); |
| 118 | + |
| 119 | + it('returns the approved gloss from initialAnalysis', () => { |
| 120 | + render( |
| 121 | + <AnalysisStoreProvider initialAnalysis={makeAnalysisWithGloss('tok-1', 'hello')}> |
| 122 | + <GlossReader tokenRef="tok-1" /> |
| 123 | + </AnalysisStoreProvider>, |
| 124 | + ); |
| 125 | + expect(screen.getByTestId('gloss')).toHaveTextContent('hello'); |
| 126 | + }); |
| 127 | + |
| 128 | + it('returns empty string for a token with a non-approved link in initialAnalysis', () => { |
| 129 | + const ta: TokenAnalysis = { id: 'ta-1', surfaceText: 'word', gloss: { en: 'hi' } }; |
| 130 | + const link: TokenAnalysisLink = { |
| 131 | + analysisId: 'ta-1', |
| 132 | + status: 'suggested', |
| 133 | + token: { tokenRef: 'tok-1', surfaceText: 'word' }, |
| 134 | + }; |
| 135 | + const analysis: TextAnalysis = { |
| 136 | + segmentAnalyses: [], |
| 137 | + segmentAnalysisLinks: [], |
| 138 | + tokenAnalyses: [ta], |
| 139 | + tokenAnalysisLinks: [link], |
| 140 | + phraseAnalyses: [], |
| 141 | + phraseAnalysisLinks: [], |
| 142 | + }; |
| 143 | + render( |
| 144 | + <AnalysisStoreProvider initialAnalysis={analysis}> |
| 145 | + <GlossReader tokenRef="tok-1" /> |
| 146 | + </AnalysisStoreProvider>, |
| 147 | + ); |
| 148 | + expect(screen.getByTestId('gloss')).toHaveTextContent(''); |
| 149 | + }); |
| 150 | + |
| 151 | + it('updates when the subscribed token is glossed via dispatch', async () => { |
| 152 | + render( |
| 153 | + <AnalysisStoreProvider> |
| 154 | + <GlossReader tokenRef="tok-1" /> |
| 155 | + <GlossWriter tokenRef="tok-1" surfaceText="word" value="world" /> |
| 156 | + </AnalysisStoreProvider>, |
| 157 | + ); |
| 158 | + expect(screen.getByTestId('gloss')).toHaveTextContent(''); |
| 159 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 160 | + expect(screen.getByTestId('gloss')).toHaveTextContent('world'); |
| 161 | + }); |
| 162 | + |
| 163 | + it('does not re-render when a different token is glossed', async () => { |
| 164 | + let renderCount = 0; |
| 165 | + |
| 166 | + function CountingGlossReader({ tokenRef }: Readonly<{ tokenRef: string }>) { |
| 167 | + renderCount += 1; |
| 168 | + const gloss = useGloss(tokenRef); |
| 169 | + return <span data-testid="gloss">{gloss}</span>; |
| 170 | + } |
| 171 | + |
| 172 | + render( |
| 173 | + <AnalysisStoreProvider> |
| 174 | + <CountingGlossReader tokenRef="tok-1" /> |
| 175 | + <GlossWriter tokenRef="tok-2" surfaceText="other" value="other" /> |
| 176 | + </AnalysisStoreProvider>, |
| 177 | + ); |
| 178 | + const initialRenderCount = renderCount; |
| 179 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 180 | + expect(renderCount).toBe(initialRenderCount); |
| 181 | + }); |
| 182 | + |
| 183 | + it('uses the analysisLanguage prop to resolve the gloss', () => { |
| 184 | + const ta: TokenAnalysis = { id: 'ta-1', surfaceText: 'mot', gloss: { fr: 'bonjour' } }; |
| 185 | + const link: TokenAnalysisLink = { |
| 186 | + analysisId: 'ta-1', |
| 187 | + status: 'approved', |
| 188 | + token: { tokenRef: 'tok-1', surfaceText: 'mot' }, |
| 189 | + }; |
| 190 | + const analysis: TextAnalysis = { |
| 191 | + segmentAnalyses: [], |
| 192 | + segmentAnalysisLinks: [], |
| 193 | + tokenAnalyses: [ta], |
| 194 | + tokenAnalysisLinks: [link], |
| 195 | + phraseAnalyses: [], |
| 196 | + phraseAnalysisLinks: [], |
| 197 | + }; |
| 198 | + render( |
| 199 | + <AnalysisStoreProvider initialAnalysis={analysis} analysisLanguage="fr"> |
| 200 | + <GlossReader tokenRef="tok-1" /> |
| 201 | + </AnalysisStoreProvider>, |
| 202 | + ); |
| 203 | + expect(screen.getByTestId('gloss')).toHaveTextContent('bonjour'); |
| 204 | + }); |
| 205 | + |
| 206 | + it('throws when called outside an AnalysisStoreProvider', () => { |
| 207 | + jest.spyOn(console, 'error').mockImplementation(() => {}); |
| 208 | + expect(() => render(<GlossReader tokenRef="tok-1" />)).toThrow( |
| 209 | + 'useGloss must be used inside an AnalysisStoreProvider', |
| 210 | + ); |
| 211 | + }); |
| 212 | +}); |
| 213 | + |
| 214 | +describe('useAnalysis', () => { |
| 215 | + it('returns an empty analysis when no initialAnalysis is provided', () => { |
| 216 | + render( |
| 217 | + <AnalysisStoreProvider> |
| 218 | + <AnalysisReader /> |
| 219 | + </AnalysisStoreProvider>, |
| 220 | + ); |
| 221 | + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); |
| 222 | + expect(analysis.tokenAnalyses).toHaveLength(0); |
| 223 | + expect(analysis.tokenAnalysisLinks).toHaveLength(0); |
| 224 | + }); |
| 225 | + |
| 226 | + it('returns seeded analyses from initialAnalysis', () => { |
| 227 | + const seed = makeAnalysisWithGloss('tok-1', 'hi'); |
| 228 | + render( |
| 229 | + <AnalysisStoreProvider initialAnalysis={seed}> |
| 230 | + <AnalysisReader /> |
| 231 | + </AnalysisStoreProvider>, |
| 232 | + ); |
| 233 | + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); |
| 234 | + expect(analysis.tokenAnalyses).toHaveLength(1); |
| 235 | + expect(analysis.tokenAnalysisLinks).toHaveLength(1); |
| 236 | + }); |
| 237 | + |
| 238 | + it('updates after a gloss write', async () => { |
| 239 | + render( |
| 240 | + <AnalysisStoreProvider> |
| 241 | + <AnalysisReader /> |
| 242 | + <GlossWriter tokenRef="tok-1" surfaceText="word" value="world" /> |
| 243 | + </AnalysisStoreProvider>, |
| 244 | + ); |
| 245 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 246 | + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); |
| 247 | + expect(analysis.tokenAnalyses).toHaveLength(1); |
| 248 | + expect(analysis.tokenAnalyses[0].gloss).toStrictEqual({ en: 'world' }); |
| 249 | + expect(analysis.tokenAnalysisLinks[0].status).toBe('approved'); |
| 250 | + }); |
| 251 | + |
| 252 | + it('throws when called outside an AnalysisStoreProvider', () => { |
| 253 | + jest.spyOn(console, 'error').mockImplementation(() => {}); |
| 254 | + expect(() => render(<AnalysisReader />)).toThrow( |
| 255 | + 'useAnalysis must be used inside an AnalysisStoreProvider', |
| 256 | + ); |
| 257 | + }); |
| 258 | +}); |
| 259 | + |
| 260 | +describe('useGlossDispatch', () => { |
| 261 | + it('creates a new approved TokenAnalysis on each write', async () => { |
| 262 | + render( |
| 263 | + <AnalysisStoreProvider> |
| 264 | + <AnalysisReader /> |
| 265 | + <GlossWriter tokenRef="tok-1" surfaceText="word" value="hi" /> |
| 266 | + </AnalysisStoreProvider>, |
| 267 | + ); |
| 268 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 269 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 270 | + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); |
| 271 | + expect(analysis.tokenAnalyses).toHaveLength(2); |
| 272 | + expect(analysis.tokenAnalysisLinks).toHaveLength(2); |
| 273 | + analysis.tokenAnalysisLinks.forEach((link) => expect(link.status).toBe('approved')); |
| 274 | + }); |
| 275 | + |
| 276 | + it('does not touch existing suggested analyses on write', async () => { |
| 277 | + const suggested: TokenAnalysis = { |
| 278 | + id: 'suggested-1', |
| 279 | + surfaceText: 'word', |
| 280 | + gloss: { en: 'old' }, |
| 281 | + }; |
| 282 | + const suggestedLink: TokenAnalysisLink = { |
| 283 | + analysisId: 'suggested-1', |
| 284 | + status: 'suggested', |
| 285 | + token: { tokenRef: 'tok-1', surfaceText: 'word' }, |
| 286 | + }; |
| 287 | + const seed: TextAnalysis = { |
| 288 | + segmentAnalyses: [], |
| 289 | + segmentAnalysisLinks: [], |
| 290 | + tokenAnalyses: [suggested], |
| 291 | + tokenAnalysisLinks: [suggestedLink], |
| 292 | + phraseAnalyses: [], |
| 293 | + phraseAnalysisLinks: [], |
| 294 | + }; |
| 295 | + render( |
| 296 | + <AnalysisStoreProvider initialAnalysis={seed}> |
| 297 | + <AnalysisReader /> |
| 298 | + <GlossWriter tokenRef="tok-1" surfaceText="word" value="new" /> |
| 299 | + </AnalysisStoreProvider>, |
| 300 | + ); |
| 301 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 302 | + const analysis: TextAnalysis = JSON.parse(screen.getByTestId('analysis').textContent ?? ''); |
| 303 | + expect(analysis.tokenAnalyses).toHaveLength(2); |
| 304 | + const suggestedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'suggested'); |
| 305 | + const approvedEntry = analysis.tokenAnalysisLinks.find((l) => l.status === 'approved'); |
| 306 | + expect(suggestedEntry?.analysisId).toBe('suggested-1'); |
| 307 | + expect(approvedEntry?.analysisId).not.toBe('suggested-1'); |
| 308 | + }); |
| 309 | + |
| 310 | + it('calls the onGlossChange spy with tokenRef and value', async () => { |
| 311 | + const spy = jest.fn(); |
| 312 | + render( |
| 313 | + <AnalysisStoreProvider onGlossChange={spy}> |
| 314 | + <GlossWriter tokenRef="tok-1" surfaceText="word" value="hi" /> |
| 315 | + </AnalysisStoreProvider>, |
| 316 | + ); |
| 317 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 318 | + expect(spy).toHaveBeenCalledTimes(1); |
| 319 | + expect(spy).toHaveBeenCalledWith('tok-1', 'hi'); |
| 320 | + }); |
| 321 | + |
| 322 | + it('calls onSave with the updated TextAnalysis', async () => { |
| 323 | + const onSave = jest.fn(); |
| 324 | + render( |
| 325 | + <AnalysisStoreProvider onSave={onSave}> |
| 326 | + <GlossWriter tokenRef="tok-1" surfaceText="word" value="hi" /> |
| 327 | + </AnalysisStoreProvider>, |
| 328 | + ); |
| 329 | + await userEvent.click(screen.getByRole('button', { name: 'write' })); |
| 330 | + expect(onSave).toHaveBeenCalledTimes(1); |
| 331 | + const saved: TextAnalysis = onSave.mock.calls[0][0]; |
| 332 | + expect(saved.tokenAnalyses[0].gloss).toStrictEqual({ en: 'hi' }); |
| 333 | + }); |
| 334 | + |
| 335 | + it('throws when called outside an AnalysisStoreProvider', () => { |
| 336 | + jest.spyOn(console, 'error').mockImplementation(() => {}); |
| 337 | + expect(() => render(<DispatchUser />)).toThrow( |
| 338 | + 'useGlossDispatch must be used inside an AnalysisStoreProvider', |
| 339 | + ); |
| 340 | + }); |
| 341 | +}); |
0 commit comments