Skip to content

Commit eccd6dc

Browse files
Lift gloss state into GlossStoreProvider; add snap-to-active-verse button
- Replace the `glosses`/`onGlossChange` prop-drilling pattern with a `GlossStoreProvider` context backed by `useSyncExternalStore`, so `TokenChip` reads and writes glosses directly without intermediary props passing through `PhraseBox`, `SegmentView`, `ContinuousView`, and `Interlinearizer`. - Add a sticky "Scroll to active verse" button (using the `LocateFixed` icon) that imperatively scrolls the `aria-current` segment into view. Rename `chapterSegments` to `bookSegments` throughout.
1 parent d6e53fb commit eccd6dc

17 files changed

Lines changed: 1055 additions & 522 deletions

__mocks__/lucide-react.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* @file Jest mock for lucide-react. Provides stub icon components used by extension components.
3+
*/
4+
5+
import type { ReactElement } from 'react';
6+
7+
/**
8+
* Stub for the LocateFixed icon; renders a bare SVG element so tests can locate the icon by test
9+
* ID.
10+
*
11+
* @param props - SVG props forwarded from the component, including optional className.
12+
* @returns A ReactElement SVG element used as a locate-fixed icon stub in tests.
13+
*/
14+
export function LocateFixed(props: Readonly<{ className?: string }>): ReactElement {
15+
return <svg data-testid="locate-fixed-icon" {...props} />;
16+
}

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 75 additions & 53 deletions
Large diffs are not rendered by default.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/** @file Unit tests for components/GlossStore.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 {
8+
GlossStoreProvider,
9+
useAllGlosses,
10+
useGloss,
11+
useGlossDispatch,
12+
} from '../../components/GlossStore';
13+
14+
// ---------------------------------------------------------------------------
15+
// Helpers
16+
// ---------------------------------------------------------------------------
17+
18+
/**
19+
* Renders a component that displays the gloss for a single token, used to assert on `useGloss`.
20+
*
21+
* @param tokenId - Token id to subscribe to.
22+
* @returns JSX element suitable for passing to `render`.
23+
*/
24+
function GlossReader({ tokenId }: Readonly<{ tokenId: string }>) {
25+
const gloss = useGloss(tokenId);
26+
return <span data-testid="gloss">{gloss}</span>;
27+
}
28+
29+
/**
30+
* Renders a component that displays all glosses as JSON, used to assert on `useAllGlosses`.
31+
*
32+
* @returns JSX element suitable for passing to `render`.
33+
*/
34+
function AllGlossesReader() {
35+
const glosses = useAllGlosses();
36+
return <span data-testid="all-glosses">{JSON.stringify(glosses)}</span>;
37+
}
38+
39+
/**
40+
* Renders a component that calls `useGlossDispatch` without a provider, used to assert the hook
41+
* throws outside a {@link GlossStoreProvider}.
42+
*
43+
* @returns Nothing — only mounted to trigger the throw.
44+
*/
45+
function DispatchUser() {
46+
useGlossDispatch();
47+
return undefined;
48+
}
49+
50+
/**
51+
* Renders a button that calls `useGlossDispatch` to write a gloss, used to test dispatch.
52+
*
53+
* @param props.tokenId - Token id to write.
54+
* @param props.value - Gloss value to write.
55+
* @returns JSX element suitable for passing to `render`.
56+
*/
57+
function GlossWriter({ tokenId, value }: Readonly<{ tokenId: string; value: string }>) {
58+
const dispatch = useGlossDispatch();
59+
return (
60+
<button onClick={() => dispatch(tokenId, value)} type="button">
61+
write
62+
</button>
63+
);
64+
}
65+
66+
// ---------------------------------------------------------------------------
67+
// Tests
68+
// ---------------------------------------------------------------------------
69+
70+
describe('useGloss', () => {
71+
it('returns an empty string for an unknown token', () => {
72+
render(
73+
<GlossStoreProvider>
74+
<GlossReader tokenId="tok-1" />
75+
</GlossStoreProvider>,
76+
);
77+
expect(screen.getByTestId('gloss')).toHaveTextContent('');
78+
});
79+
80+
it('returns the seeded value for a token in initialGlosses', () => {
81+
render(
82+
<GlossStoreProvider initialGlosses={{ 'tok-1': 'hello' }}>
83+
<GlossReader tokenId="tok-1" />
84+
</GlossStoreProvider>,
85+
);
86+
expect(screen.getByTestId('gloss')).toHaveTextContent('hello');
87+
});
88+
89+
it('updates when the subscribed token changes', async () => {
90+
render(
91+
<GlossStoreProvider>
92+
<GlossReader tokenId="tok-1" />
93+
<GlossWriter tokenId="tok-1" value="world" />
94+
</GlossStoreProvider>,
95+
);
96+
expect(screen.getByTestId('gloss')).toHaveTextContent('');
97+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
98+
expect(screen.getByTestId('gloss')).toHaveTextContent('world');
99+
});
100+
101+
it('does not update when a different token changes', async () => {
102+
let renderCount = 0;
103+
104+
function CountingGlossReader({ tokenId }: Readonly<{ tokenId: string }>) {
105+
renderCount += 1;
106+
const gloss = useGloss(tokenId);
107+
return <span data-testid="gloss">{gloss}</span>;
108+
}
109+
110+
render(
111+
<GlossStoreProvider>
112+
<CountingGlossReader tokenId="tok-1" />
113+
<GlossWriter tokenId="tok-2" value="other" />
114+
</GlossStoreProvider>,
115+
);
116+
const initialRenderCount = renderCount;
117+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
118+
expect(renderCount).toBe(initialRenderCount);
119+
});
120+
121+
it('throws when called outside a GlossStoreProvider', () => {
122+
jest.spyOn(console, 'error').mockImplementation(() => {});
123+
expect(() => render(<GlossReader tokenId="tok-1" />)).toThrow(
124+
'useGloss must be used inside a GlossStoreProvider',
125+
);
126+
});
127+
});
128+
129+
describe('useAllGlosses', () => {
130+
it('returns an empty object when no glosses have been set', () => {
131+
render(
132+
<GlossStoreProvider>
133+
<AllGlossesReader />
134+
</GlossStoreProvider>,
135+
);
136+
expect(screen.getByTestId('all-glosses')).toHaveTextContent('{}');
137+
});
138+
139+
it('returns seeded glosses from initialGlosses', () => {
140+
render(
141+
<GlossStoreProvider initialGlosses={{ 'tok-1': 'hi' }}>
142+
<AllGlossesReader />
143+
</GlossStoreProvider>,
144+
);
145+
expect(screen.getByTestId('all-glosses')).toHaveTextContent(JSON.stringify({ 'tok-1': 'hi' }));
146+
});
147+
148+
it('updates when any token changes', async () => {
149+
render(
150+
<GlossStoreProvider>
151+
<AllGlossesReader />
152+
<GlossWriter tokenId="tok-1" value="world" />
153+
</GlossStoreProvider>,
154+
);
155+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
156+
expect(screen.getByTestId('all-glosses')).toHaveTextContent(
157+
JSON.stringify({ 'tok-1': 'world' }),
158+
);
159+
});
160+
161+
it('throws when called outside a GlossStoreProvider', () => {
162+
jest.spyOn(console, 'error').mockImplementation(() => {});
163+
expect(() => render(<AllGlossesReader />)).toThrow(
164+
'useAllGlosses must be used inside a GlossStoreProvider',
165+
);
166+
});
167+
});
168+
169+
describe('useGlossDispatch', () => {
170+
it('calls the onGlossChange spy with tokenId and value', async () => {
171+
const spy = jest.fn();
172+
render(
173+
<GlossStoreProvider onGlossChange={spy}>
174+
<GlossWriter tokenId="tok-1" value="hi" />
175+
</GlossStoreProvider>,
176+
);
177+
await userEvent.click(screen.getByRole('button', { name: 'write' }));
178+
expect(spy).toHaveBeenCalledTimes(1);
179+
expect(spy).toHaveBeenCalledWith('tok-1', 'hi');
180+
});
181+
182+
it('throws when called outside a GlossStoreProvider', () => {
183+
jest.spyOn(console, 'error').mockImplementation(() => {});
184+
expect(() => render(<DispatchUser />)).toThrow(
185+
'useGlossDispatch must be used inside a GlossStoreProvider',
186+
);
187+
});
188+
});

0 commit comments

Comments
 (0)