@@ -88,12 +88,22 @@ jest.mock('../../components/TokenLinkIcon', () => ({
8888
8989jest . mock ( '../../components/ArcOverlay' , ( ) => ( {
9090 __esModule : true ,
91+ // Surface the props ContinuousView derives and forwards (hoveredPhraseId, candidatePhraseIds) as
92+ // data attributes so DOM queries can assert on values that otherwise only live inside ArcOverlay.
9193 default : ( {
9294 onArcSplit,
93- } : Readonly < { onArcSplit : ( phraseId : string , splitAfterTokenRef : string ) => void } > ) => (
95+ hoveredPhraseId,
96+ candidatePhraseIds,
97+ } : Readonly < {
98+ onArcSplit : ( phraseId : string , splitAfterTokenRef : string ) => void ;
99+ hoveredPhraseId : string | undefined ;
100+ candidatePhraseIds : ReadonlySet < string > ;
101+ } > ) => (
94102 < button
95103 type = "button"
96104 data-testid = "arc-split-btn"
105+ data-hovered-phrase-id = { hoveredPhraseId ?? '' }
106+ data-candidate-phrase-ids = { [ ...candidatePhraseIds ] . join ( ',' ) }
97107 onClick = { ( ) => onArcSplit ( 'phrase-1' , 'tok-0' ) }
98108 >
99109 split
@@ -1267,26 +1277,69 @@ describe('ContinuousView phrase grouping', () => {
12671277 expect ( phraseBoxes [ 1 ] ) . toHaveAttribute ( 'data-show-gloss' , 'false' ) ;
12681278 } ) ;
12691279
1270- it ( 'fires mouse-leave on the token strip without throwing' , async ( ) => {
1280+ it ( 'clears the hovered phrase highlight when the pointer leaves the token strip' , async ( ) => {
1281+ // Group tok-0/tok-1 into one hoverable phrase so hovering it sets hoveredPhraseId, which
1282+ // ContinuousView forwards to ArcOverlay. Leaving the strip runs clearAllHoverState, which must
1283+ // reset hoveredPhraseId to undefined.
1284+ const phraseLink : PhraseAnalysisLink = {
1285+ analysisId : 'phrase-1' ,
1286+ status : 'approved' ,
1287+ tokens : [
1288+ { tokenRef : 'tok-0' , surfaceText : 'In' } ,
1289+ { tokenRef : 'tok-1' , surfaceText : 'the' } ,
1290+ ] ,
1291+ } ;
1292+ phraseLinkMap . set ( 'tok-0' , phraseLink ) ;
1293+ phraseLinkMap . set ( 'tok-1' , phraseLink ) ;
12711294 const book = makeBook ( ) ;
12721295 render ( < ContinuousView { ...requiredProps ( book ) } /> , withAnalysisStore ) ;
1273- const strip = screen . getByTestId ( 'token-strip' ) ;
1274- await userEvent . unhover ( strip ) ;
1275- // No throw = pass
1296+
1297+ // Hover the phrase group to set hoveredPhraseId='phrase-1'.
1298+ const phraseGroupSpan = document . querySelector ( '[data-phrase-box="true"]' ) ?. parentElement ;
1299+ if ( ! phraseGroupSpan ) throw new Error ( 'Expected a phrase group wrapper span' ) ;
1300+ await userEvent . hover ( phraseGroupSpan ) ;
1301+ expect ( screen . getByTestId ( 'arc-split-btn' ) ) . toHaveAttribute (
1302+ 'data-hovered-phrase-id' ,
1303+ 'phrase-1' ,
1304+ ) ;
1305+
1306+ // Leaving the strip itself (not the group) must clear the highlight via clearAllHoverState.
1307+ fireEvent . mouseLeave ( screen . getByTestId ( 'token-strip' ) ) ;
1308+ expect ( screen . getByTestId ( 'arc-split-btn' ) ) . toHaveAttribute ( 'data-hovered-phrase-id' , '' ) ;
12761309 } ) ;
12771310
12781311 it ( 'applies the internal focus transition when the parent reflects a click-driven ref change' , async ( ) => {
12791312 // Simulate: ContinuousView clicks Next, sets internalFocusedTokenRefRef, calls
12801313 // onFocusedTokenRefChange. The parent then passes the new focusedTokenRef back. This exercises
1281- // the isInternal=true path (lines 306-308) of the pending-jump effect.
1314+ // the isInternal=true branch of the focus-change effect, which applies the new ref *immediately*
1315+ // (setDisplayFocusedTokenRef) without fading the strip out. The external (non-internal) branch
1316+ // would instead defer the display update behind a fade timeout, so the focused box would still
1317+ // be 'In' (tok-0) right after the rerender.
12821318 const book = makeBook ( ) ;
12831319 const props = requiredProps ( book , { focusedTokenRef : 'tok-0' } ) ;
12841320 const { rerender } = render ( < ContinuousView { ...props } /> , withAnalysisStore ) ;
12851321
1322+ // Sanity: tok-0's box ('In') is focused before the click.
1323+ expect ( screen . getByText ( 'In' ) . closest ( '[data-phrase-box="true"]' ) ) . toHaveAttribute (
1324+ 'data-focus-state' ,
1325+ 'focused' ,
1326+ ) ;
1327+
12861328 await userEvent . click ( screen . getByRole ( 'button' , { name : 'Next token' } ) ) ;
1287- // Now reflect the new ref back as a prop change (as a real parent would do).
1329+ // Now reflect the new ref back as a prop change (as a real parent would do). Because the click
1330+ // stamped tok-1 as internally-originated, the echo is recognized as internal and applied at once.
12881331 rerender ( < ContinuousView { ...props } focusedTokenRef = "tok-1" /> ) ;
1289- // No throw = the isInternal path ran successfully.
1332+
1333+ // The displayed focus moved synchronously to tok-1's box ('the') — the internal path, not the
1334+ // fade-then-snap external path (which would leave 'In' focused until the fade timeout fires).
1335+ expect ( screen . getByText ( 'the' ) . closest ( '[data-phrase-box="true"]' ) ) . toHaveAttribute (
1336+ 'data-focus-state' ,
1337+ 'focused' ,
1338+ ) ;
1339+ expect ( screen . getByText ( 'In' ) . closest ( '[data-phrase-box="true"]' ) ) . toHaveAttribute (
1340+ 'data-focus-state' ,
1341+ 'default' ,
1342+ ) ;
12901343 } ) ;
12911344
12921345 it ( 'scrolls to the first token of the active phrase when entering edit mode' , async ( ) => {
@@ -1400,6 +1453,25 @@ describe('ContinuousView phrase grouping', () => {
14001453 mockCandidateTokenRefs . current = new Set ( [ 'tok-0' ] ) ;
14011454 const book = makeBook ( ) ;
14021455 render ( < ContinuousView { ...requiredProps ( book ) } /> , withAnalysisStore ) ;
1403- expect ( screen . getByTestId ( 'arc-split-btn' ) ) . toBeInTheDocument ( ) ;
1456+ // useCandidatePhraseIds resolves the hovered candidate ref (tok-0) to the phrase that contains
1457+ // it, and ContinuousView forwards the set to ArcOverlay. The mock surfaces it as a data attr.
1458+ expect ( screen . getByTestId ( 'arc-split-btn' ) ) . toHaveAttribute (
1459+ 'data-candidate-phrase-ids' ,
1460+ 'phrase-1' ,
1461+ ) ;
1462+ } ) ;
1463+
1464+ it ( 'computes an empty candidatePhraseIds set when no candidate tokens are hovered' , ( ) => {
1465+ const phraseLink : PhraseAnalysisLink = {
1466+ analysisId : 'phrase-1' ,
1467+ status : 'approved' ,
1468+ tokens : [ { tokenRef : 'tok-0' , surfaceText : 'In' } ] ,
1469+ } ;
1470+ phraseLinkMap . set ( 'tok-0' , phraseLink ) ;
1471+ // No hovered candidate refs: the phrase exists, but nothing should resolve to it.
1472+ mockCandidateTokenRefs . current = new Set ( ) ;
1473+ const book = makeBook ( ) ;
1474+ render ( < ContinuousView { ...requiredProps ( book ) } /> , withAnalysisStore ) ;
1475+ expect ( screen . getByTestId ( 'arc-split-btn' ) ) . toHaveAttribute ( 'data-candidate-phrase-ids' , '' ) ;
14041476 } ) ;
14051477} ) ;
0 commit comments