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