Skip to content

Commit 2833e40

Browse files
Add useArcSplitHandler hook
1 parent 456b8bd commit 2833e40

4 files changed

Lines changed: 154 additions & 58 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/** @file Unit tests for the useArcSplitHandler hook. */
2+
/// <reference types="jest" />
3+
4+
import { renderHook } from '@testing-library/react';
5+
import type { PhraseAnalysisLink } from 'interlinearizer';
6+
import { useArcSplitHandler } from '../../hooks/useArcSplitHandler';
7+
8+
// ---------------------------------------------------------------------------
9+
// AnalysisStore mock — supply spyable phrase dispatch callbacks
10+
// ---------------------------------------------------------------------------
11+
12+
const mockCreatePhrase = jest.fn<string, [unknown]>().mockReturnValue('new-phrase');
13+
const mockUpdatePhrase = jest.fn();
14+
const mockDeletePhrase = jest.fn();
15+
16+
jest.mock('../../components/AnalysisStore', () => ({
17+
__esModule: true,
18+
usePhraseDispatch: () => ({
19+
createPhrase: mockCreatePhrase,
20+
updatePhrase: mockUpdatePhrase,
21+
deletePhrase: mockDeletePhrase,
22+
}),
23+
}));
24+
25+
/**
26+
* Builds a phrase link map keyed by each token ref, mirroring how {@link usePhraseLinkMap} indexes
27+
* its links so the hook's `.values()` lookup finds the link.
28+
*
29+
* @param link - The phrase link to index.
30+
* @returns A map from every token ref in the link to the link itself.
31+
*/
32+
function linkMap(link: PhraseAnalysisLink): Map<string, PhraseAnalysisLink> {
33+
const map = new Map<string, PhraseAnalysisLink>();
34+
link.tokens.forEach((t) => map.set(t.tokenRef, link));
35+
return map;
36+
}
37+
38+
describe('useArcSplitHandler', () => {
39+
beforeEach(() => {
40+
mockCreatePhrase.mockReturnValue('new-phrase');
41+
});
42+
43+
it('is a no-op when no phrase in the map matches the given id', () => {
44+
const link: PhraseAnalysisLink = {
45+
analysisId: 'phrase-1',
46+
status: 'approved',
47+
tokens: [
48+
{ tokenRef: 'tok-0', surfaceText: 'In' },
49+
{ tokenRef: 'tok-1', surfaceText: 'the' },
50+
],
51+
};
52+
const { result } = renderHook(() => useArcSplitHandler(linkMap(link), new Map()));
53+
54+
result.current('phrase-absent', 'tok-0');
55+
56+
expect(mockCreatePhrase).not.toHaveBeenCalled();
57+
expect(mockUpdatePhrase).not.toHaveBeenCalled();
58+
expect(mockDeletePhrase).not.toHaveBeenCalled();
59+
});
60+
61+
it('deletes the phrase when both halves of a two-token split are solo', () => {
62+
const link: PhraseAnalysisLink = {
63+
analysisId: 'phrase-1',
64+
status: 'approved',
65+
tokens: [
66+
{ tokenRef: 'tok-0', surfaceText: 'In' },
67+
{ tokenRef: 'tok-1', surfaceText: 'the' },
68+
],
69+
};
70+
const { result } = renderHook(() => useArcSplitHandler(linkMap(link), new Map()));
71+
72+
result.current('phrase-1', 'tok-0');
73+
74+
expect(mockDeletePhrase).toHaveBeenCalledWith('phrase-1');
75+
expect(mockCreatePhrase).not.toHaveBeenCalled();
76+
expect(mockUpdatePhrase).not.toHaveBeenCalled();
77+
});
78+
79+
it('orders tokens by tokenDocOrder before slicing, splitting into two new phrases', () => {
80+
// Stored out of document order to prove tokenDocOrder drives the boundary.
81+
const link: PhraseAnalysisLink = {
82+
analysisId: 'phrase-1',
83+
status: 'approved',
84+
tokens: [
85+
{ tokenRef: 'tok-3', surfaceText: 'd' },
86+
{ tokenRef: 'tok-0', surfaceText: 'a' },
87+
{ tokenRef: 'tok-2', surfaceText: 'c' },
88+
{ tokenRef: 'tok-1', surfaceText: 'b' },
89+
],
90+
};
91+
const tokenDocOrder = new Map([
92+
['tok-0', 0],
93+
['tok-1', 1],
94+
['tok-2', 2],
95+
['tok-3', 3],
96+
]);
97+
const { result } = renderHook(() => useArcSplitHandler(linkMap(link), tokenDocOrder));
98+
99+
result.current('phrase-1', 'tok-1');
100+
101+
expect(mockUpdatePhrase).toHaveBeenCalledWith('phrase-1', [
102+
{ tokenRef: 'tok-0', surfaceText: 'a' },
103+
{ tokenRef: 'tok-1', surfaceText: 'b' },
104+
]);
105+
expect(mockCreatePhrase).toHaveBeenCalledWith([
106+
{ tokenRef: 'tok-2', surfaceText: 'c' },
107+
{ tokenRef: 'tok-3', surfaceText: 'd' },
108+
]);
109+
});
110+
});

src/components/ContinuousView.tsx

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import type { Book, Token } from 'interlinearizer';
22
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
import type { Dispatch, SetStateAction } from 'react';
4-
import { usePhraseLinkMap, usePhraseDispatch } from './AnalysisStore';
4+
import { usePhraseLinkMap } from './AnalysisStore';
55
import type { PhraseMode } from '../types/phrase-mode';
66
import { PhraseGroup, PhraseSlot, resolveIsHighlighted } from './PhraseStripParts';
77
import {
88
ARC_BASE_STEM,
99
ARC_CORNER_RADIUS,
1010
ARC_LEVEL_STEP,
1111
CONTROLS_HALF_HEIGHT_PX,
12-
splitPhraseAtBoundary,
1312
} from '../utils/phrase-arc';
1413
import {
1514
buildRenderUnits,
@@ -19,6 +18,7 @@ import {
1918
type TokenGroup,
2019
} from '../utils/token-layout';
2120
import { useArcPaths } from '../hooks/useArcPaths';
21+
import { useArcSplitHandler } from '../hooks/useArcSplitHandler';
2222
import { useCandidatePhraseIds } from '../hooks/useCandidatePhraseIds';
2323
import MemoizedArcOverlay, { type ArcSplitTarget } from './ArcOverlay';
2424

@@ -133,7 +133,6 @@ export default function ContinuousView({
133133
);
134134

135135
const committedPhraseLinkByRef = usePhraseLinkMap();
136-
const { createPhrase, updatePhrase, deletePhrase } = usePhraseDispatch();
137136

138137
/**
139138
* Token list of the phrase currently being edited, or `undefined` outside edit mode. Hoisted to a
@@ -293,33 +292,7 @@ export default function ContinuousView({
293292
[focusedTokenRef],
294293
);
295294

296-
/**
297-
* Splits a discontiguous phrase at the boundary encoded in an arc path. Resolves the phrase from
298-
* the committed link map and delegates the actual split to {@link splitPhraseAtBoundary}.
299-
*
300-
* @param phraseId - ID of the phrase to split.
301-
* @param splitAfterTokenRef - Ref of the last token in the earlier fragment; the split occurs
302-
* immediately after this token.
303-
*/
304-
const handleArcSplit = useCallback(
305-
(phraseId: string, splitAfterTokenRef: string) => {
306-
const phraseLink = [...committedPhraseLinkByRef.values()].find(
307-
(l) => l.analysisId === phraseId,
308-
);
309-
if (!phraseLink) return;
310-
splitPhraseAtBoundary(
311-
phraseLink,
312-
splitAfterTokenRef,
313-
{
314-
createPhrase,
315-
updatePhrase,
316-
deletePhrase,
317-
},
318-
tokenDocOrder,
319-
);
320-
},
321-
[committedPhraseLinkByRef, createPhrase, updatePhrase, deletePhrase, tokenDocOrder],
322-
);
295+
const handleArcSplit = useArcSplitHandler(committedPhraseLinkByRef, tokenDocOrder);
323296

324297
// React to changes in the prop `focusedTokenRef`. For internal nav (arrow/click in this view),
325298
// apply the change immediately and smooth-scroll. For external jumps (segment-mode click,

src/components/SegmentView.tsx

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import type { ScriptureRef, Segment, Token } from 'interlinearizer';
22
import { memo, useCallback, useMemo, useRef, useState } from 'react';
33
import type { Dispatch, SetStateAction } from 'react';
4-
import { usePhraseLinkMap, usePhraseDispatch } from './AnalysisStore';
4+
import { usePhraseLinkMap } from './AnalysisStore';
55
import type { PhraseMode } from '../types/phrase-mode';
66
import { PhraseGroup, PhraseSlot, resolveIsHighlighted } from './PhraseStripParts';
77
import {
88
ARC_BASE_STEM,
99
ARC_CORNER_RADIUS,
1010
ARC_LEVEL_STEP,
1111
CONTROLS_HALF_HEIGHT_PX,
12-
splitPhraseAtBoundary,
1312
} from '../utils/phrase-arc';
1413
import {
1514
buildRenderUnits,
@@ -18,6 +17,7 @@ import {
1817
type RenderUnit,
1918
} from '../utils/token-layout';
2019
import { useArcPaths } from '../hooks/useArcPaths';
20+
import { useArcSplitHandler } from '../hooks/useArcSplitHandler';
2121
import { useCandidatePhraseIds } from '../hooks/useCandidatePhraseIds';
2222
import MemoizedArcOverlay, { type ArcSplitTarget } from './ArcOverlay';
2323

@@ -113,7 +113,6 @@ export function SegmentView({
113113
const ref: ScriptureRef = useMemo(() => ({ book, chapter, verse }), [book, chapter, verse]);
114114

115115
const phraseLinkByRef = usePhraseLinkMap();
116-
const { createPhrase, updatePhrase, deletePhrase } = usePhraseDispatch();
117116

118117
/** Maps each token ref to its flat index within this segment for document-order phrase merges. */
119118
const tokenDocOrder = useMemo(() => {
@@ -122,31 +121,7 @@ export function SegmentView({
122121
return map;
123122
}, [segment.tokens]);
124123

125-
/**
126-
* Splits a discontiguous phrase at the arc boundary ending at `splitAfterTokenRef`. Resolves the
127-
* phrase from the link map and delegates to {@link splitPhraseAtBoundary}, passing `tokenDocOrder`
128-
* so the split slices the phrase in document order.
129-
*
130-
* @param phraseId - ID of the phrase to split.
131-
* @param splitAfterTokenRef - Ref of the last token in the earlier fragment.
132-
*/
133-
const handleArcSplit = useCallback(
134-
(phraseId: string, splitAfterTokenRef: string) => {
135-
const phraseLink = [...phraseLinkByRef.values()].find((l) => l.analysisId === phraseId);
136-
if (!phraseLink) return;
137-
splitPhraseAtBoundary(
138-
phraseLink,
139-
splitAfterTokenRef,
140-
{
141-
createPhrase,
142-
updatePhrase,
143-
deletePhrase,
144-
},
145-
tokenDocOrder,
146-
);
147-
},
148-
[phraseLinkByRef, createPhrase, updatePhrase, deletePhrase, tokenDocOrder],
149-
);
124+
const handleArcSplit = useArcSplitHandler(phraseLinkByRef, tokenDocOrder);
150125

151126
/**
152127
* Forwards a token-chip click (identified by the group's first-token ref) to the parent as a

src/hooks/useArcSplitHandler.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useCallback } from 'react';
2+
import type { PhraseAnalysisLink } from 'interlinearizer';
3+
import { usePhraseDispatch } from '../components/AnalysisStore';
4+
import { splitPhraseAtBoundary } from '../utils/phrase-arc';
5+
6+
/**
7+
* Builds the arc-split callback shared by SegmentView and ContinuousView so the two strip layouts
8+
* can never drift apart in how a discontiguous phrase is split. Pulls the phrase create/update/
9+
* delete dispatchers internally, resolves the target phrase from the supplied link map, and
10+
* delegates the actual split to {@link splitPhraseAtBoundary}.
11+
*
12+
* @param phraseLinkByRef - The committed phrase link map to resolve the phrase from, keyed by token
13+
* ref.
14+
* @param tokenDocOrder - Map from token ref to flat document index, used to order the phrase's
15+
* tokens before slicing so the split honours visual (document) order.
16+
* @returns A callback `(phraseId, splitAfterTokenRef)` that splits the named phrase at the boundary
17+
* immediately after `splitAfterTokenRef`; a no-op when the phrase is absent from the link map.
18+
*/
19+
export function useArcSplitHandler(
20+
phraseLinkByRef: Map<string, PhraseAnalysisLink>,
21+
tokenDocOrder: ReadonlyMap<string, number>,
22+
): (phraseId: string, splitAfterTokenRef: string) => void {
23+
const { createPhrase, updatePhrase, deletePhrase } = usePhraseDispatch();
24+
25+
return useCallback(
26+
(phraseId: string, splitAfterTokenRef: string) => {
27+
const phraseLink = [...phraseLinkByRef.values()].find((l) => l.analysisId === phraseId);
28+
if (!phraseLink) return;
29+
splitPhraseAtBoundary(
30+
phraseLink,
31+
splitAfterTokenRef,
32+
{ createPhrase, updatePhrase, deletePhrase },
33+
tokenDocOrder,
34+
);
35+
},
36+
[phraseLinkByRef, tokenDocOrder, createPhrase, updatePhrase, deletePhrase],
37+
);
38+
}

0 commit comments

Comments
 (0)