Skip to content

Commit cfa0a17

Browse files
"Prevent" verse zero from being included (currently broken, pushing so I can work on this elsewhere)
1 parent fba0220 commit cfa0a17

14 files changed

Lines changed: 559 additions & 24 deletions

src/__tests__/components/PhraseStripParts.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ describe('PhraseSlot boundary controls', () => {
337337
split: jest.fn(),
338338
move: jest.fn(),
339339
},
340+
verseZeroSegmentIds: ReadonlySet<string> = new Set(),
340341
) {
341342
const value: SegmentationContextValue = {
342343
dispatch,
@@ -346,6 +347,7 @@ describe('PhraseSlot boundary controls', () => {
346347
['seg-1', 0],
347348
['seg-2', 1],
348349
]),
350+
verseZeroSegmentIds,
349351
};
350352
render(
351353
<SegmentationProvider value={value}>
@@ -374,6 +376,26 @@ describe('PhraseSlot boundary controls', () => {
374376
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
375377
});
376378

379+
it('still shows the merge control when the next segment is a verse-0 superscription', () => {
380+
const dispatch = renderBoundary(
381+
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' },
382+
undefined,
383+
new Set(['seg-2']),
384+
);
385+
fireEvent.click(screen.getByTestId('boundary-merge-btn'));
386+
expect(dispatch.merge).toHaveBeenCalledWith('seg2-start');
387+
});
388+
389+
it('renders no split control inside a verse-0 superscription segment', () => {
390+
renderBoundary(
391+
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-1' },
392+
undefined,
393+
new Set(['seg-1']),
394+
);
395+
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
396+
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
397+
});
398+
377399
it('renders no control at a leading slot with no previous segment', () => {
378400
renderBoundary({
379401
prevSegmentId: undefined,

src/__tests__/components/SegmentationStore.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('SegmentationStore', () => {
4040
boundaryEditMode: true,
4141
segmentById: new Map([['GEN 1:1', segment]]),
4242
segmentOrder: new Map([['GEN 1:1', 0]]),
43+
verseZeroSegmentIds: new Set(),
4344
};
4445
render(
4546
<SegmentationProvider value={value}>

src/__tests__/components/TokenLinkIcon.test.tsx

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ describe('TokenLinkIcon', () => {
633633
['seg-A', 0],
634634
['seg-B', 1],
635635
]),
636+
verseZeroSegmentIds: new Set(),
636637
};
637638
const tokenSegmentMap =
638639
opts.mapToken === false ? new Map<string, string>() : new Map([['tok-b', 'seg-B']]);
@@ -712,5 +713,266 @@ describe('TokenLinkIcon', () => {
712713
expect(button).toBeDisabled();
713714
expect(button).toHaveAttribute('title', 'nope');
714715
});
716+
717+
// -------------------------------------------------------------------------
718+
// Reach past a verse-0 superscription (sweep verse 0 + pull the token beyond it)
719+
// -------------------------------------------------------------------------
720+
721+
describe('reaching past a verse-0 superscription', () => {
722+
/**
723+
* Builds a segment from word-token refs.
724+
*
725+
* @param id - Segment id.
726+
* @param verse - Verse number (0 marks a superscription).
727+
* @param refs - Word token refs in order.
728+
* @returns The segment.
729+
*/
730+
function makeSeg(id: string, verse: number, refs: string[]): Segment {
731+
return {
732+
id,
733+
startRef: { book: 'GEN', chapter: 1, verse },
734+
endRef: { book: 'GEN', chapter: 1, verse },
735+
baselineText: refs.join(' '),
736+
tokens: refs.map((r) => makeWordToken(r)),
737+
};
738+
}
739+
740+
/** Options for {@link renderSkip}. */
741+
type SkipOpts = {
742+
/** Pull direction: `true` = focus before verse 0 (forward), `false` = after it (backward). */
743+
focusedSideIsPrev: boolean;
744+
/** Word refs of the segment before verse 0. */
745+
aTokens: string[];
746+
/** Word refs of the verse-0 superscription. */
747+
v0Tokens: string[];
748+
/** Word refs of the segment after verse 0; omit to model verse 0 at the book edge. */
749+
bTokens?: string[];
750+
/** A phrase containing the resolved beyond-token, to exercise the neighbor-phrase path. */
751+
beyondPhrase?: ReturnType<typeof makePhraseLink>;
752+
/** Focused token ref override (defaults to the focused segment's edge word). */
753+
focusedRef?: string;
754+
/** Whether the focused token is itself inside the verse-0 segment. */
755+
focusInVerseZero?: boolean;
756+
};
757+
758+
/**
759+
* Renders a link icon at the slot bordering a verse-0 superscription, with three segments (A
760+
*
761+
* | V0 | B) wired into both providers.
762+
*
763+
* @param dispatch - The segmentation dispatch to capture calls on.
764+
* @param opts - Segment layout and focus configuration.
765+
* @returns The render result.
766+
*/
767+
function renderSkip(dispatch: SegmentationDispatch, opts: SkipOpts) {
768+
const aSeg = makeSeg('seg-A', 1, opts.aTokens);
769+
const v0Seg = makeSeg('seg-V0', 0, opts.v0Tokens);
770+
const bSeg = opts.bTokens ? makeSeg('seg-B', 2, opts.bTokens) : undefined;
771+
const segmentById = new Map<string, Segment>([
772+
['seg-A', aSeg],
773+
['seg-V0', v0Seg],
774+
]);
775+
const segmentOrder = new Map([
776+
['seg-A', 0],
777+
['seg-V0', 1],
778+
]);
779+
if (bSeg) {
780+
segmentById.set('seg-B', bSeg);
781+
segmentOrder.set('seg-B', 2);
782+
}
783+
const tokenSegmentMap = new Map<string, string>();
784+
opts.aTokens.forEach((r) => tokenSegmentMap.set(r, 'seg-A'));
785+
opts.v0Tokens.forEach((r) => tokenSegmentMap.set(r, 'seg-V0'));
786+
opts.bTokens?.forEach((r) => tokenSegmentMap.set(r, 'seg-B'));
787+
788+
// Forward: slot sits between A's last word and verse 0's first word, focus is in A.
789+
// Backward: slot sits between verse 0's last word and B's first word, focus is in B.
790+
const lastAToken = opts.aTokens[opts.aTokens.length - 1];
791+
const firstBToken =
792+
/* v8 ignore next -- backward/forward tests always pass bTokens */
793+
opts.bTokens?.[0] ?? 'none';
794+
const prevToken = opts.focusedSideIsPrev
795+
? makeWordToken(lastAToken)
796+
: makeWordToken(opts.v0Tokens[opts.v0Tokens.length - 1]);
797+
const nextToken = opts.focusedSideIsPrev
798+
? makeWordToken(opts.v0Tokens[0])
799+
: makeWordToken(firstBToken);
800+
const edgeFocusedRef = opts.focusedSideIsPrev ? lastAToken : firstBToken;
801+
const defaultFocusedRef = opts.focusInVerseZero ? opts.v0Tokens[0] : edgeFocusedRef;
802+
803+
const segmentation: SegmentationContextValue = {
804+
dispatch,
805+
boundaryEditMode: false,
806+
segmentById,
807+
segmentOrder,
808+
verseZeroSegmentIds: new Set(['seg-V0']),
809+
};
810+
const allRefs = [...opts.aTokens, ...opts.v0Tokens, ...(opts.bTokens ?? [])];
811+
const tokenDocOrder = new Map(allRefs.map((r, i) => [r, i]));
812+
const phraseLinkByRef = new Map<string, ReturnType<typeof makePhraseLink>>();
813+
if (opts.beyondPhrase) {
814+
opts.beyondPhrase.tokens.forEach((t) => {
815+
if (opts.beyondPhrase) phraseLinkByRef.set(t.tokenRef, opts.beyondPhrase);
816+
});
817+
}
818+
return render(
819+
<SegmentationProvider value={segmentation}>
820+
<PhraseStripProvider
821+
value={makePhraseStripContext({ tokenSegmentMap, tokenDocOrder, phraseLinkByRef })}
822+
>
823+
<TokenLinkIcon
824+
{...requiredProps()}
825+
prevToken={prevToken}
826+
nextToken={nextToken}
827+
slotFocus={slotFocus({
828+
focusedSideIsPrev: opts.focusedSideIsPrev,
829+
isSameSegmentAsFocus: false,
830+
isAdjacentEdgeOfFocus: true,
831+
focusedFreeToken: makeWordToken(opts.focusedRef ?? defaultFocusedRef),
832+
})}
833+
/>
834+
</PhraseStripProvider>
835+
</SegmentationProvider>,
836+
);
837+
}
838+
839+
it('sweeps verse 0 and pulls the first word beyond it when focus is the previous segment', async () => {
840+
const dispatch = makeDispatch();
841+
renderSkip(dispatch, {
842+
focusedSideIsPrev: true,
843+
aTokens: ['a1', 'a2'],
844+
v0Tokens: ['z1', 'z2'],
845+
bTokens: ['b1', 'b2'],
846+
});
847+
await userEvent.click(screen.getByTestId('token-link-btn'));
848+
// Merge verse 0 into A, then pull b1 (leaving b2 as B's new start).
849+
expect(dispatch.merge).toHaveBeenCalledWith('z1');
850+
expect(dispatch.move).toHaveBeenCalledWith('b1', 'b2');
851+
// The phrase links the focused A token with the token beyond verse 0, not a verse-0 token.
852+
expect(mockCreatePhrase).toHaveBeenCalledWith([
853+
{ tokenRef: 'a2', surfaceText: 'a2' },
854+
{ tokenRef: 'b1', surfaceText: 'b1' },
855+
]);
856+
});
857+
858+
it('merges the whole segment beyond verse 0 when it has only the pulled token', async () => {
859+
const dispatch = makeDispatch();
860+
renderSkip(dispatch, {
861+
focusedSideIsPrev: true,
862+
aTokens: ['a1', 'a2'],
863+
v0Tokens: ['z1'],
864+
bTokens: ['b1'],
865+
});
866+
await userEvent.click(screen.getByTestId('token-link-btn'));
867+
expect(dispatch.merge).toHaveBeenCalledWith('z1');
868+
expect(dispatch.merge).toHaveBeenCalledWith('b1');
869+
});
870+
871+
it('sweeps verse 0 and pulls the last word before it when focus is the next segment', async () => {
872+
const dispatch = makeDispatch();
873+
renderSkip(dispatch, {
874+
focusedSideIsPrev: false,
875+
aTokens: ['a1', 'a2'],
876+
v0Tokens: ['z1', 'z2'],
877+
bTokens: ['b1', 'b2'],
878+
});
879+
await userEvent.click(screen.getByTestId('token-link-btn'));
880+
// Merge verse 0 into A, then move B's start back to A's last word a2.
881+
expect(dispatch.merge).toHaveBeenCalledWith('z1');
882+
expect(dispatch.move).toHaveBeenCalledWith('b1', 'a2');
883+
});
884+
885+
it('absorbs the focused token into the beyond-token phrase when one exists', async () => {
886+
const dispatch = makeDispatch();
887+
renderSkip(dispatch, {
888+
focusedSideIsPrev: true,
889+
aTokens: ['a1', 'a2'],
890+
v0Tokens: ['z1', 'z2'],
891+
bTokens: ['b1', 'b2'],
892+
beyondPhrase: makePhraseLink('p-beyond', ['b1']),
893+
});
894+
await userEvent.click(screen.getByTestId('token-link-btn'));
895+
expect(dispatch.merge).toHaveBeenCalledWith('z1');
896+
// Focused free token a2 is absorbed into the phrase that already contains b1.
897+
expect(mockUpdatePhrase).toHaveBeenCalledWith(
898+
'p-beyond',
899+
expect.arrayContaining([{ tokenRef: 'a2', surfaceText: 'a2' }]),
900+
);
901+
});
902+
903+
it('disables the link when verse 0 is at the book edge with nothing beyond it', () => {
904+
renderSkip(makeDispatch(), {
905+
focusedSideIsPrev: true,
906+
aTokens: ['a1', 'a2'],
907+
v0Tokens: ['z1', 'z2'],
908+
// No bTokens: verse 0 ends the book, so there is no segment to reach.
909+
});
910+
expect(screen.getByTestId('token-link-btn')).toBeDisabled();
911+
});
912+
913+
it('disables the link when the focused token is itself inside verse 0', () => {
914+
renderSkip(makeDispatch(), {
915+
focusedSideIsPrev: true,
916+
aTokens: ['a1', 'a2'],
917+
v0Tokens: ['z1', 'z2'],
918+
bTokens: ['b1', 'b2'],
919+
focusInVerseZero: true,
920+
});
921+
expect(screen.getByTestId('token-link-btn')).toBeDisabled();
922+
});
923+
924+
it('highlights the token beyond verse 0 on hover', async () => {
925+
const onHoverCandidateTokens = jest.fn();
926+
const dispatch = makeDispatch();
927+
const aSeg = makeSeg('seg-A', 1, ['a1', 'a2']);
928+
const v0Seg = makeSeg('seg-V0', 0, ['z1', 'z2']);
929+
const bSeg = makeSeg('seg-B', 2, ['b1', 'b2']);
930+
const tokenSegmentMap = new Map([
931+
['a1', 'seg-A'],
932+
['a2', 'seg-A'],
933+
['z1', 'seg-V0'],
934+
['z2', 'seg-V0'],
935+
['b1', 'seg-B'],
936+
['b2', 'seg-B'],
937+
]);
938+
render(
939+
<SegmentationProvider
940+
value={{
941+
dispatch,
942+
boundaryEditMode: false,
943+
segmentById: new Map([
944+
['seg-A', aSeg],
945+
['seg-V0', v0Seg],
946+
['seg-B', bSeg],
947+
]),
948+
segmentOrder: new Map([
949+
['seg-A', 0],
950+
['seg-V0', 1],
951+
['seg-B', 2],
952+
]),
953+
verseZeroSegmentIds: new Set(['seg-V0']),
954+
}}
955+
>
956+
<PhraseStripProvider
957+
value={makePhraseStripContext({ tokenSegmentMap, onHoverCandidateTokens })}
958+
>
959+
<TokenLinkIcon
960+
{...requiredProps()}
961+
prevToken={makeWordToken('a2')}
962+
nextToken={makeWordToken('z1')}
963+
slotFocus={slotFocus({
964+
focusedSideIsPrev: true,
965+
isSameSegmentAsFocus: false,
966+
isAdjacentEdgeOfFocus: true,
967+
focusedFreeToken: makeWordToken('a2'),
968+
})}
969+
/>
970+
</PhraseStripProvider>
971+
</SegmentationProvider>,
972+
);
973+
await userEvent.hover(screen.getByTestId('token-link-btn'));
974+
expect(onHoverCandidateTokens).toHaveBeenCalledWith(['a2', 'b1']);
975+
});
976+
});
715977
});
716978
});

src/__tests__/test-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function makePhraseStripContext(
7878
editPhraseSegmentId: undefined,
7979
tokenSegmentMap: new Map(),
8080
tokenDocOrder: new Map(),
81+
phraseLinkByRef: new Map(),
8182
onHoverPhrase: () => {},
8283
onHoverCandidateTokens: () => {},
8384
onHoverSplitFreeTokens: () => {},

0 commit comments

Comments
 (0)