@@ -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} ) ;
0 commit comments