22/// <reference types="jest" />
33/// <reference types="@testing-library/jest-dom" />
44
5+ import { useLocalizedStrings } from '@papi/frontend/react' ;
56import { fireEvent , render , screen } from '@testing-library/react' ;
6- import type { PhraseAnalysisLink , Token } from 'interlinearizer' ;
7+ import type { PhraseAnalysisLink , Segment , Token } from 'interlinearizer' ;
78import type { ReactElement } from 'react' ;
89import {
910 PhraseSlot ,
@@ -12,6 +13,10 @@ import {
1213 type StripItem ,
1314} from '../../components/PhraseStripParts' ;
1415import { PhraseStripProvider } from '../../components/PhraseStripContext' ;
16+ import {
17+ SegmentationProvider ,
18+ type SegmentationContextValue ,
19+ } from '../../components/SegmentationStore' ;
1520import { emptyFocusContext } from '../../types/empty-factories' ;
1621import type { TokenGroup , LinkSlot , FocusContext } from '../../types/token-layout' ;
1722import { makePhraseLink , makePhraseStripContext , makeWordToken } from '../test-helpers' ;
@@ -279,6 +284,106 @@ describe('PhraseSlot', () => {
279284 } ) ;
280285} ) ;
281286
287+ // ---------------------------------------------------------------------------
288+ // PhraseSlot boundary controls (boundary-edit mode)
289+ // ---------------------------------------------------------------------------
290+
291+ describe ( 'PhraseSlot boundary controls' , ( ) => {
292+ // resetMocks clears the shared useLocalizedStrings implementation, so re-establish the
293+ // key-to-itself mapping the BoundaryControl labels rely on.
294+ beforeEach ( ( ) => {
295+ jest
296+ . mocked ( useLocalizedStrings )
297+ . mockImplementation ( ( keys : readonly string [ ] ) => [
298+ keys . reduce < Record < string , string > > ( ( acc , k ) => ( { ...acc , [ k ] : k } ) , { } ) ,
299+ false ,
300+ ] ) ;
301+ } ) ;
302+
303+ const groupA : TokenGroup = {
304+ tokens : [ makeWordToken ( 'a' ) ] ,
305+ phraseLink : undefined ,
306+ firstIndex : 0 ,
307+ punctuationBetween : [ ] ,
308+ } ;
309+ const groupB : TokenGroup = {
310+ tokens : [ makeWordToken ( 'b' ) ] ,
311+ phraseLink : undefined ,
312+ firstIndex : 1 ,
313+ punctuationBetween : [ ] ,
314+ } ;
315+ const slot : LinkSlot = { prevGroup : groupA , nextGroup : groupB , punctuation : [ ] } ;
316+
317+ /** A segment whose first token ref identifies the boundary the merge control removes. */
318+ const nextSegment : Segment = {
319+ id : 'seg-2' ,
320+ startRef : { book : 'GEN' , chapter : 1 , verse : 2 } ,
321+ endRef : { book : 'GEN' , chapter : 1 , verse : 2 } ,
322+ baselineText : 'b' ,
323+ tokens : [ makeWordToken ( 'seg2-start' ) ] ,
324+ } ;
325+
326+ /**
327+ * Renders a PhraseSlot inside both providers with boundary-edit mode on.
328+ *
329+ * @param props - Overrides for the slot props (e.g. prev/next segment ids).
330+ * @param dispatch - The segmentation dispatch to capture calls on.
331+ * @returns The render result.
332+ */
333+ function renderBoundary (
334+ props : Partial < Parameters < typeof PhraseSlot > [ 0 ] > ,
335+ dispatch = {
336+ merge : jest . fn ( ) ,
337+ split : jest . fn ( ) ,
338+ move : jest . fn ( ) ,
339+ } ,
340+ ) {
341+ const value : SegmentationContextValue = {
342+ dispatch,
343+ boundaryEditMode : true ,
344+ segmentById : new Map ( [ [ 'seg-2' , nextSegment ] ] ) ,
345+ segmentOrder : new Map ( [
346+ [ 'seg-1' , 0 ] ,
347+ [ 'seg-2' , 1 ] ,
348+ ] ) ,
349+ } ;
350+ render (
351+ < SegmentationProvider value = { value } >
352+ < PhraseStripProvider value = { makePhraseStripContext ( ) } >
353+ < PhraseSlot { ...slotProps ( slot ) } { ...props } />
354+ </ PhraseStripProvider >
355+ </ SegmentationProvider > ,
356+ ) ;
357+ return dispatch ;
358+ }
359+
360+ it ( 'shows a merge control on a cross-segment slot and merges on click' , ( ) => {
361+ const dispatch = renderBoundary ( { prevSegmentId : 'seg-1' , nextSegmentId : 'seg-2' } ) ;
362+ const button = screen . getByTestId ( 'boundary-merge-btn' ) ;
363+ fireEvent . click ( button ) ;
364+ expect ( dispatch . merge ) . toHaveBeenCalledWith ( 'seg2-start' ) ;
365+ expect ( screen . queryByTestId ( 'boundary-split-btn' ) ) . not . toBeInTheDocument ( ) ;
366+ } ) ;
367+
368+ it ( 'shows a split control on an intra-segment slot and splits on click' , ( ) => {
369+ const dispatch = renderBoundary ( { prevSegmentId : 'seg-1' , nextSegmentId : 'seg-1' } ) ;
370+ const button = screen . getByTestId ( 'boundary-split-btn' ) ;
371+ fireEvent . click ( button ) ;
372+ // The next group's first token ref is the split anchor.
373+ expect ( dispatch . split ) . toHaveBeenCalledWith ( 'b' ) ;
374+ expect ( screen . queryByTestId ( 'boundary-merge-btn' ) ) . not . toBeInTheDocument ( ) ;
375+ } ) ;
376+
377+ it ( 'renders no control at a leading slot with no previous segment' , ( ) => {
378+ renderBoundary ( {
379+ prevSegmentId : undefined ,
380+ nextSegmentId : 'seg-1' ,
381+ } ) ;
382+ expect ( screen . queryByTestId ( 'boundary-merge-btn' ) ) . not . toBeInTheDocument ( ) ;
383+ expect ( screen . queryByTestId ( 'boundary-split-btn' ) ) . not . toBeInTheDocument ( ) ;
384+ } ) ;
385+ } ) ;
386+
282387// ---------------------------------------------------------------------------
283388// PhraseGroup
284389// ---------------------------------------------------------------------------
0 commit comments