Skip to content

Commit 3d697ef

Browse files
Ensure continuous view scroll is smooth, hoist shared components into dedicated mock files
1 parent 200d1f7 commit 3d697ef

7 files changed

Lines changed: 102 additions & 137 deletions

File tree

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,7 @@ const withAnalysisStore = {
4747
},
4848
};
4949

50-
jest.mock('../../components/TokenChip', () => ({
51-
__esModule: true,
52-
MemoizedInertTokenChip({ token }: Readonly<{ token: Token }>) {
53-
return <span>{token.surfaceText}</span>;
54-
},
55-
}));
50+
jest.mock('../../components/TokenChip');
5651

5752
jest.mock('../../components/PhraseBox', () => ({
5853
__esModule: true,

src/__tests__/components/PhraseBox.test.tsx

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,71 +5,10 @@
55
import { render, screen } from '@testing-library/react';
66
import userEvent from '@testing-library/user-event';
77
import type { AssignmentStatus, Token } from 'interlinearizer';
8-
import type { ReactNode } from 'react';
98
import { AnalysisStoreProvider } from '../../components/AnalysisStore';
109
import { PhraseBox } from '../../components/PhraseBox';
1110

12-
// ---------------------------------------------------------------------------
13-
// AnalysisStore mock — reactive useState-based stub so AnalysisStore.tsx stays out of scope
14-
// ---------------------------------------------------------------------------
15-
16-
jest.mock('../../components/AnalysisStore', () => {
17-
const { createContext, useCallback, useContext, useMemo, useState } =
18-
jest.requireActual<typeof import('react')>('react');
19-
20-
type GlossMap = Record<string, string>;
21-
type MockCtxValue = {
22-
glosses: GlossMap;
23-
dispatch: (tokenRef: string, surfaceText: string, value: string) => void;
24-
};
25-
const MockCtx = createContext<MockCtxValue>({ glosses: {}, dispatch: () => {} });
26-
27-
return {
28-
__esModule: true,
29-
AnalysisStoreProvider({
30-
children,
31-
initialAnalysis,
32-
analysisLanguage,
33-
onGlossChange,
34-
}: Readonly<{
35-
children: ReactNode;
36-
initialAnalysis?: {
37-
tokenAnalyses: { id: string; gloss?: GlossMap }[];
38-
tokenAnalysisLinks: {
39-
analysisId: string;
40-
status: AssignmentStatus;
41-
token: { tokenRef: string };
42-
}[];
43-
};
44-
analysisLanguage: string;
45-
onGlossChange?: (tokenRef: string, value: string) => void;
46-
}>) {
47-
const byId = new Map((initialAnalysis?.tokenAnalyses ?? []).map((ta) => [ta.id, ta]));
48-
const seed: GlossMap = (initialAnalysis?.tokenAnalysisLinks ?? [])
49-
.filter((link) => link.status === 'approved')
50-
.reduce((acc, link) => {
51-
const gloss = byId.get(link.analysisId)?.gloss?.[analysisLanguage];
52-
return gloss === undefined ? acc : { ...acc, [link.token.tokenRef]: gloss };
53-
}, {});
54-
const [glosses, setGlosses] = useState<GlossMap>(seed);
55-
const dispatch = useCallback(
56-
(tokenRef: string, _surfaceText: string, value: string) => {
57-
setGlosses((prev) => ({ ...prev, [tokenRef]: value }));
58-
onGlossChange?.(tokenRef, value);
59-
},
60-
[onGlossChange],
61-
);
62-
const ctx = useMemo(() => ({ glosses, dispatch }), [glosses, dispatch]);
63-
return <MockCtx value={ctx}>{children}</MockCtx>;
64-
},
65-
useGloss(tokenRef: string) {
66-
return useContext(MockCtx).glosses[tokenRef] ?? '';
67-
},
68-
useGlossDispatch() {
69-
return useContext(MockCtx).dispatch;
70-
},
71-
};
72-
});
11+
jest.mock('../../components/AnalysisStore');
7312

7413
jest.mock('../../components/TokenChip', () => {
7514
const { useGloss, useGlossDispatch } = jest.requireMock<

src/__tests__/components/SegmentView.test.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@ jest.mock('../../components/AnalysisStore', () => ({
2222
useGlossDispatch: () => () => {},
2323
}));
2424

25-
jest.mock('../../components/TokenChip', () => ({
26-
__esModule: true,
27-
MemoizedInertTokenChip({ token }: Readonly<{ token: Token }>) {
28-
return <span>{token.surfaceText}</span>;
29-
},
30-
}));
25+
jest.mock('../../components/TokenChip');
3126

3227
jest.mock('../../components/PhraseBox', () => ({
3328
__esModule: true,

src/__tests__/components/TokenChip.test.tsx

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,71 +5,10 @@
55
import { render, screen } from '@testing-library/react';
66
import userEvent from '@testing-library/user-event';
77
import type { AssignmentStatus, Token } from 'interlinearizer';
8-
import type { ReactNode } from 'react';
98
import { AnalysisStoreProvider } from '../../components/AnalysisStore';
109
import { InertTokenChip, TokenChip } from '../../components/TokenChip';
1110

12-
// ---------------------------------------------------------------------------
13-
// AnalysisStore mock — reactive useState-based stub so AnalysisStore.tsx stays out of scope
14-
// ---------------------------------------------------------------------------
15-
16-
jest.mock('../../components/AnalysisStore', () => {
17-
const { createContext, useCallback, useContext, useMemo, useState } =
18-
jest.requireActual<typeof import('react')>('react');
19-
20-
type GlossMap = Record<string, string>;
21-
type MockCtxValue = {
22-
glosses: GlossMap;
23-
dispatch: (tokenRef: string, surfaceText: string, value: string) => void;
24-
};
25-
const MockCtx = createContext<MockCtxValue>({ glosses: {}, dispatch: () => {} });
26-
27-
return {
28-
__esModule: true,
29-
AnalysisStoreProvider({
30-
children,
31-
initialAnalysis,
32-
analysisLanguage,
33-
onGlossChange,
34-
}: Readonly<{
35-
children: ReactNode;
36-
initialAnalysis?: {
37-
tokenAnalyses: { id: string; gloss?: GlossMap }[];
38-
tokenAnalysisLinks: {
39-
analysisId: string;
40-
status: AssignmentStatus;
41-
token: { tokenRef: string };
42-
}[];
43-
};
44-
analysisLanguage: string;
45-
onGlossChange?: (tokenRef: string, value: string) => void;
46-
}>) {
47-
const byId = new Map((initialAnalysis?.tokenAnalyses ?? []).map((ta) => [ta.id, ta]));
48-
const seed: GlossMap = (initialAnalysis?.tokenAnalysisLinks ?? [])
49-
.filter((link) => link.status === 'approved')
50-
.reduce((acc, link) => {
51-
const gloss = byId.get(link.analysisId)?.gloss?.[analysisLanguage];
52-
return gloss === undefined ? acc : { ...acc, [link.token.tokenRef]: gloss };
53-
}, {});
54-
const [glosses, setGlosses] = useState<GlossMap>(seed);
55-
const dispatch = useCallback(
56-
(tokenRef: string, _surfaceText: string, value: string) => {
57-
setGlosses((prev) => ({ ...prev, [tokenRef]: value }));
58-
onGlossChange?.(tokenRef, value);
59-
},
60-
[onGlossChange],
61-
);
62-
const ctx = useMemo(() => ({ glosses, dispatch }), [glosses, dispatch]);
63-
return <MockCtx value={ctx}>{children}</MockCtx>;
64-
},
65-
useGloss(tokenId: string) {
66-
return useContext(MockCtx).glosses[tokenId] ?? '';
67-
},
68-
useGlossDispatch() {
69-
return useContext(MockCtx).dispatch;
70-
},
71-
};
72-
});
11+
jest.mock('../../components/AnalysisStore');
7312

7413
const WORD_TOKEN = {
7514
ref: 'GEN 1:1:0',

src/components/TokenChip.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Token } from 'interlinearizer';
2-
import { memo, useEffect, useState } from 'react';
2+
import { memo, type MouseEventHandler, useEffect, useState } from 'react';
33
import { useGloss, useGlossDispatch } from './AnalysisStore';
44

55
/**
@@ -27,6 +27,13 @@ export function TokenChip({
2727
setDraft(committedGloss);
2828
}, [committedGloss]);
2929

30+
const handleMouseDown: MouseEventHandler<HTMLInputElement> = (e) => {
31+
// Prevent the browser's built-in focus-and-scroll so only the React-controlled
32+
// smooth scrollIntoView fires. We re-focus manually with preventScroll instead.
33+
e.preventDefault();
34+
e.currentTarget.focus({ preventScroll: true });
35+
};
36+
3037
return (
3138
<label className="tw:inline-flex tw:shrink-0 tw:flex-col tw:items-center tw:rounded tw:border tw:border-border tw:bg-muted tw:px-1.5 tw:py-0.5">
3239
<span className="tw:whitespace-nowrap tw:font-mono tw:text-sm tw:text-foreground tw:cursor-text">
@@ -42,6 +49,7 @@ export function TokenChip({
4249
if (draft !== committedGloss) onGlossChange(token.ref, token.surfaceText, draft);
4350
}}
4451
onFocus={onFocus}
52+
onMouseDown={handleMouseDown}
4553
type="text"
4654
/>
4755
</label>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/** @file Manual mock for AnalysisStore — reactive useState-based stub so AnalysisStore.tsx stays out of test scope. */
2+
3+
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
4+
import type { ReactNode } from 'react';
5+
import type { AssignmentStatus } from 'interlinearizer';
6+
7+
type GlossMap = Record<string, string>;
8+
type MockCtxValue = {
9+
glosses: GlossMap;
10+
dispatch: (tokenRef: string, surfaceText: string, value: string) => void;
11+
};
12+
const MockCtx = createContext<MockCtxValue>({ glosses: {}, dispatch: () => {} });
13+
14+
/**
15+
* Test-only provider that seeds glosses from `initialAnalysis` and keeps them in local state,
16+
* forwarding updates to `onGlossChange` without depending on the real AnalysisStore.
17+
*
18+
* @param props - Same surface props as the real `AnalysisStoreProvider`.
19+
* @returns A React element wrapping `children` in the mock context.
20+
*/
21+
export function AnalysisStoreProvider({
22+
children,
23+
initialAnalysis,
24+
analysisLanguage,
25+
onGlossChange,
26+
}: Readonly<{
27+
children: ReactNode;
28+
initialAnalysis?: {
29+
tokenAnalyses: { id: string; gloss?: GlossMap }[];
30+
tokenAnalysisLinks: {
31+
analysisId: string;
32+
status: AssignmentStatus;
33+
token: { tokenRef: string };
34+
}[];
35+
};
36+
analysisLanguage: string;
37+
onGlossChange?: (tokenRef: string, value: string) => void;
38+
}>) {
39+
const byId = new Map((initialAnalysis?.tokenAnalyses ?? []).map((ta) => [ta.id, ta]));
40+
const seed: GlossMap = (initialAnalysis?.tokenAnalysisLinks ?? [])
41+
.filter((link) => link.status === 'approved')
42+
.reduce((acc, link) => {
43+
const gloss = byId.get(link.analysisId)?.gloss?.[analysisLanguage];
44+
return gloss === undefined ? acc : { ...acc, [link.token.tokenRef]: gloss };
45+
}, {});
46+
const [glosses, setGlosses] = useState<GlossMap>(seed);
47+
const dispatch = useCallback(
48+
(tokenRef: string, _surfaceText: string, value: string) => {
49+
setGlosses((prev) => ({ ...prev, [tokenRef]: value }));
50+
onGlossChange?.(tokenRef, value);
51+
},
52+
[onGlossChange],
53+
);
54+
const ctx = useMemo(() => ({ glosses, dispatch }), [glosses, dispatch]);
55+
return <MockCtx value={ctx}>{children}</MockCtx>;
56+
}
57+
58+
/**
59+
* Returns the committed gloss for a token, or an empty string if none is set.
60+
*
61+
* @param tokenRef - The token reference key.
62+
* @returns The current gloss string from mock context.
63+
*/
64+
export function useGloss(tokenRef: string) {
65+
return useContext(MockCtx).glosses[tokenRef] ?? '';
66+
}
67+
68+
/**
69+
* Returns the dispatch function that updates a token's gloss in mock context.
70+
*
71+
* @returns The mock dispatch function.
72+
*/
73+
export function useGlossDispatch() {
74+
return useContext(MockCtx).dispatch;
75+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** @file Manual mock for TokenChip — renders surface text only, keeping TokenChip.tsx out of test scope. */
2+
3+
import type { Token } from 'interlinearizer';
4+
5+
/**
6+
* Minimal stub for `MemoizedInertTokenChip` that renders the token's surface text in a span.
7+
*
8+
* @param props - Component props.
9+
* @param props.token - The token whose surface text is rendered.
10+
* @returns A span containing the token's surface text.
11+
*/
12+
export function MemoizedInertTokenChip({ token }: Readonly<{ token: Token }>) {
13+
return <span>{token.surfaceText}</span>;
14+
}

0 commit comments

Comments
 (0)