Skip to content

Commit 69f8147

Browse files
Draw hard boundary at verse-0 segments
1 parent 65ff976 commit 69f8147

11 files changed

Lines changed: 147 additions & 403 deletions

src/__tests__/components/PhraseStripParts.test.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,24 @@ describe('PhraseSlot boundary controls', () => {
376376
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
377377
});
378378

379-
it('still shows the merge control when the next segment is a verse-0 superscription', () => {
380-
const dispatch = renderBoundary(
379+
it('renders no merge control when the next segment is a verse-0 superscription', () => {
380+
renderBoundary(
381381
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' },
382382
undefined,
383383
new Set(['seg-2']),
384384
);
385-
fireEvent.click(screen.getByTestId('boundary-merge-btn'));
386-
expect(dispatch.merge).toHaveBeenCalledWith('seg2-start');
385+
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
386+
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
387+
});
388+
389+
it('renders no merge control when the previous segment is a verse-0 superscription', () => {
390+
renderBoundary(
391+
{ prevSegmentId: 'seg-1', nextSegmentId: 'seg-2' },
392+
undefined,
393+
new Set(['seg-1']),
394+
);
395+
expect(screen.queryByTestId('boundary-merge-btn')).not.toBeInTheDocument();
396+
expect(screen.queryByTestId('boundary-split-btn')).not.toBeInTheDocument();
387397
});
388398

389399
it('renders no split control inside a verse-0 superscription segment', () => {

src/__tests__/components/TokenLinkIcon.test.tsx

Lines changed: 30 additions & 229 deletions
Original file line numberDiff line numberDiff line change
@@ -715,263 +715,64 @@ describe('TokenLinkIcon', () => {
715715
});
716716

717717
// -------------------------------------------------------------------------
718-
// Reach past a verse-0 superscription (sweep verse 0 + pull the token beyond it)
718+
// Verse-0 superscriptions are a hard wall: no cross-segment link may touch one.
719719
// -------------------------------------------------------------------------
720720

721-
describe('reaching past a verse-0 superscription', () => {
721+
describe('verse-0 superscription hard wall', () => {
722722
/**
723-
* Builds a segment from word-token refs.
723+
* Renders a cross-segment edge slot bordering a verse-0 superscription. seg-A (a real verse)
724+
* and seg-B (the superscription) sit adjacent; `focusedSideIsPrev` picks which side holds
725+
* focus. The slot's `nextToken` is seg-B's `tok-b` and `prevToken` is seg-A's `tok-a`.
724726
*
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.
727+
* @param opts - Which side holds focus and which segment id is the verse-0 superscription.
765728
* @returns The render result.
766729
*/
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-
730+
function renderWall(opts: { focusedSideIsPrev: boolean; verseZeroSegId: 'seg-A' | 'seg-B' }) {
803731
const segmentation: SegmentationContextValue = {
804-
dispatch,
732+
dispatch: makeDispatch(),
805733
boundaryEditMode: false,
806-
segmentById,
807-
segmentOrder,
808-
verseZeroSegmentIds: new Set(['seg-V0']),
734+
segmentById: new Map([['seg-B', segB(['tok-b', 'tok-c'])]]),
735+
segmentOrder: new Map([
736+
['seg-A', 0],
737+
['seg-B', 1],
738+
]),
739+
verseZeroSegmentIds: new Set([opts.verseZeroSegId]),
809740
};
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-
}
741+
const tokenSegmentMap = new Map([
742+
['tok-a', 'seg-A'],
743+
['tok-b', 'seg-B'],
744+
]);
818745
return render(
819746
<SegmentationProvider value={segmentation}>
820747
<PhraseStripProvider
821-
value={makePhraseStripContext({ tokenSegmentMap, tokenDocOrder, phraseLinkByRef })}
748+
value={makePhraseStripContext({ tokenSegmentMap, crossSegmentLinkTooltip: 'nope' })}
822749
>
823750
<TokenLinkIcon
824751
{...requiredProps()}
825-
prevToken={prevToken}
826-
nextToken={nextToken}
827752
slotFocus={slotFocus({
828753
focusedSideIsPrev: opts.focusedSideIsPrev,
829754
isSameSegmentAsFocus: false,
830755
isAdjacentEdgeOfFocus: true,
831-
focusedFreeToken: makeWordToken(opts.focusedRef ?? defaultFocusedRef),
756+
focusedFreeToken: makeWordToken(opts.focusedSideIsPrev ? 'tok-a' : 'tok-b'),
832757
})}
833758
/>
834759
</PhraseStripProvider>
835760
</SegmentationProvider>,
836761
);
837762
}
838763

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();
764+
it('disables the link when the adjacent segment is a verse-0 superscription', () => {
765+
renderWall({ focusedSideIsPrev: true, verseZeroSegId: 'seg-B' });
766+
const button = screen.getByTestId('token-link-btn');
767+
expect(button).toBeDisabled();
768+
expect(button).toHaveAttribute('title', 'nope');
922769
});
923770

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']);
771+
it('disables the link when the focused segment is itself a verse-0 superscription', () => {
772+
renderWall({ focusedSideIsPrev: false, verseZeroSegId: 'seg-B' });
773+
const button = screen.getByTestId('token-link-btn');
774+
expect(button).toBeDisabled();
775+
expect(button).toHaveAttribute('title', 'nope');
975776
});
976777
});
977778
});

src/__tests__/test-helpers.ts

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

src/__tests__/utils/segmentation.test.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ const VZ0_START = 'GEN 2:0:0';
5454
const VZ0_INTERIOR = 'GEN 2:0:4';
5555
const VZ_NEXT_START = 'GEN 2:1:0';
5656

57+
/**
58+
* A fixture whose final segment is a verse-0 superscription, so the superscription has no segment
59+
* after it. Exercises the after-boundary branch of the verse-0 lock when verse 0 ends the book.
60+
*/
61+
const TRAILING_VERSE_ZERO = makeBook([
62+
{ sid: 'GEN 1:1', text: 'Alpha beta.' },
63+
{ sid: 'GEN 2:0', text: 'Sup tee.' },
64+
]);
65+
// Start of the trailing verse-0 superscription.
66+
const TVZ0_START = 'GEN 2:0:0';
67+
5768
describe('defaultVerseStarts', () => {
5869
it('returns the first-token ref of every verse', () => {
5970
expect(defaultVerseStarts(THREE_VERSES)).toEqual(new Set([V1_START, V2_START, V3_START]));
@@ -178,16 +189,23 @@ describe('removeBoundaryAt', () => {
178189
});
179190
});
180191

181-
it('merges a verse-0 superscription wholesale into the previous segment', () => {
192+
it('is a no-op when removing the boundary before a verse-0 superscription (would join the previous chapter)', () => {
182193
expect(removeBoundaryAt(MID_VERSE_ZERO, undefined, VZ0_START)).toEqual({
183-
removedVerseStarts: [VZ0_START],
194+
removedVerseStarts: [],
184195
addedStarts: [],
185196
});
186197
});
187198

188-
it('merges the verse after a superscription, sweeping verse 0 along with it', () => {
199+
it('is a no-op when removing the boundary after a verse-0 superscription (would sweep verse 0 forward)', () => {
189200
expect(removeBoundaryAt(MID_VERSE_ZERO, undefined, VZ_NEXT_START)).toEqual({
190-
removedVerseStarts: [VZ_NEXT_START],
201+
removedVerseStarts: [],
202+
addedStarts: [],
203+
});
204+
});
205+
206+
it('is a no-op when removing the boundary before a verse-0 superscription that ends the book', () => {
207+
expect(removeBoundaryAt(TRAILING_VERSE_ZERO, undefined, TVZ0_START)).toEqual({
208+
removedVerseStarts: [],
191209
addedStarts: [],
192210
});
193211
});
@@ -201,11 +219,11 @@ describe('moveBoundary', () => {
201219
});
202220
});
203221

204-
it('moves a verse-0 boundary but never splits the superscription (add half is a no-op)', () => {
205-
// Moving the after-superscription boundary back onto an interior verse-0 token can't split
206-
// verse 0, so it degrades to merging the following verse in.
222+
it('is a no-op when both halves touch a verse-0 superscription', () => {
223+
// The after-superscription boundary can't be removed (it borders verse 0), and an interior
224+
// verse-0 token can't become a new start, so neither half of the move applies.
207225
expect(moveBoundary(MID_VERSE_ZERO, undefined, VZ_NEXT_START, VZ0_INTERIOR)).toEqual({
208-
removedVerseStarts: [VZ_NEXT_START],
226+
removedVerseStarts: [],
209227
addedStarts: [],
210228
});
211229
});

0 commit comments

Comments
 (0)