diff --git a/src/__tests__/components/ArcOverlay.test.tsx b/src/__tests__/components/ArcOverlay.test.tsx index d51b647d..026d37db 100644 --- a/src/__tests__/components/ArcOverlay.test.tsx +++ b/src/__tests__/components/ArcOverlay.test.tsx @@ -367,9 +367,24 @@ describe('ArcOverlay', () => { />, ); const btns = screen.getAllByTestId('split-arc-btn'); - // Hover the first split button — tok-b's arc goes through focusedPhraseId branch (returns 2). + // Hover the first split button — tok-b's arc goes through focusedPhraseId branch. await userEvent.hover(btns[0]); - expect(document.querySelectorAll('path')).toHaveLength(2); + + // The free-split-hovered tok-a arc is promoted to the z-5 (hovered) layer. + const hoveredLayer = document.querySelector('svg.tw\\:z-5'); + const hoveredPaths = hoveredLayer?.querySelectorAll('path') ?? []; + expect(hoveredPaths).toHaveLength(1); + expect(hoveredPaths[0].getAttribute('d')).toContain('tok-a'); + + // tok-b's arc is classified focused via the focusedPhraseId branch, so it sits in the z-3 + // (focused) layer — not the z-1 unfocused layer it would fall to if that branch regressed. + const focusedLayer = document.querySelector('svg.tw\\:z-3'); + const focusedPaths = focusedLayer?.querySelectorAll('path') ?? []; + expect(focusedPaths).toHaveLength(1); + expect(focusedPaths[0].getAttribute('d')).toContain('tok-b'); + + // Nothing falls through to the unfocused (z-1) layer. + expect(document.querySelector('svg.tw\\:z-1')?.querySelectorAll('path')).toHaveLength(0); }); it('assigns candidate priority to an arc via candidatePhraseIds when split-hover misses its splitAfterTokenRef and focusedPhraseId does not match', async () => { @@ -395,7 +410,21 @@ describe('ArcOverlay', () => { ); const btns = screen.getAllByTestId('split-arc-btn'); await userEvent.hover(btns[0]); - expect(document.querySelectorAll('path')).toHaveLength(2); + + // Both arcs land in the z-5 (hovered) layer: tok-a via the free-split promotion, and tok-b via + // the candidatePhraseIds.has('p1') branch. If that candidate clause regressed, tok-b would drop + // to the z-1 (unfocused) layer and would no longer appear here. + const hoveredLayer = document.querySelector('svg.tw\\:z-5'); + const hoveredDs = Array.from(hoveredLayer?.querySelectorAll('path') ?? []).map((p) => + p.getAttribute('d'), + ); + expect(hoveredDs).toHaveLength(2); + expect(hoveredDs).toEqual( + expect.arrayContaining([expect.stringContaining('tok-a'), expect.stringContaining('tok-b')]), + ); + + // No arc is demoted to the unfocused (z-1) layer. + expect(document.querySelector('svg.tw\\:z-1')?.querySelectorAll('path')).toHaveLength(0); }); it('highlights the phrase via onHoverPhrase on enter when the split would not free any token', async () => { diff --git a/src/__tests__/components/ContinuousView.test.tsx b/src/__tests__/components/ContinuousView.test.tsx index 53eb5f08..dff035e8 100644 --- a/src/__tests__/components/ContinuousView.test.tsx +++ b/src/__tests__/components/ContinuousView.test.tsx @@ -88,12 +88,22 @@ jest.mock('../../components/TokenLinkIcon', () => ({ jest.mock('../../components/ArcOverlay', () => ({ __esModule: true, + // Surface the props ContinuousView derives and forwards (hoveredPhraseId, candidatePhraseIds) as + // data attributes so DOM queries can assert on values that otherwise only live inside ArcOverlay. default: ({ onArcSplit, - }: Readonly<{ onArcSplit: (phraseId: string, splitAfterTokenRef: string) => void }>) => ( + hoveredPhraseId, + candidatePhraseIds, + }: Readonly<{ + onArcSplit: (phraseId: string, splitAfterTokenRef: string) => void; + hoveredPhraseId: string | undefined; + candidatePhraseIds: ReadonlySet; + }>) => ( @@ -889,6 +893,9 @@ describe('InterlinearizerLoader', () => { await userEvent.click(screen.getByTestId('metadata-modal-deleted')); expect(screen.queryByTestId('metadata-modal')).not.toBeInTheDocument(); + // The deleted project was the active one, so the loader should now pass `activeProject: + // undefined` down to ProjectModals (the stub omits the attribute when there is no name). + expect(screen.getByTestId('project-modals')).not.toHaveAttribute('data-active-project-name'); }); it('updates the active project name when its metadata is saved', async () => { @@ -902,6 +909,12 @@ describe('InterlinearizerLoader', () => { await userEvent.click(screen.getByTestId('metadata-modal-saved')); expect(screen.queryByTestId('metadata-modal')).not.toBeInTheDocument(); + // Saving renamed the active project; the loader must reflect the new name it reads back from + // WebView state by passing it down to ProjectModals. + expect(screen.getByTestId('project-modals')).toHaveAttribute( + 'data-active-project-name', + 'Renamed Project', + ); }); it('renders without error when useData provides a topMenu with items', async () => { diff --git a/src/__tests__/components/MorphemeEditor.test.tsx b/src/__tests__/components/MorphemeEditor.test.tsx index 4c3eff3e..2950d58d 100644 --- a/src/__tests__/components/MorphemeEditor.test.tsx +++ b/src/__tests__/components/MorphemeEditor.test.tsx @@ -53,7 +53,12 @@ describe('MorphemeBreakdownPopover', () => { it('auto-focuses and selects the input on mount', () => { renderPopover({ initialValue: 'word' }); - expect(screen.getByRole('textbox')).toHaveFocus(); + const input = screen.getByRole('textbox'); + expect(input).toHaveFocus(); + // The mount effect calls .select(), so the whole value is selected and a fresh keystroke + // replaces it. Asserting the selection range catches a regression that drops the .select() call. + expect(input).toHaveProperty('selectionStart', 0); + expect(input).toHaveProperty('selectionEnd', 'word'.length); }); it('calls onSave and onClose when Done button is clicked', async () => { @@ -150,7 +155,13 @@ describe('MorphemeBreakdownPopover', () => { it('does not save on outside interaction when the input is only whitespace', async () => { const onSave = jest.fn(); - renderPopover({ initialValue: ' ', onSave }); + // Start from a real word and edit it down to whitespace so the draft differs from initialValue + // (isUnedited is false). This forces handleInteractOutside past the unedited guard into + // handleSave, where the isMeaningless check is what rejects the empty breakdown — the behavior + // this test names. If isMeaningless were removed, handleSave would call onSave and this fails. + renderPopover({ initialValue: 'word', onSave, surfaceText: 'whole' }); + await userEvent.clear(screen.getByRole('textbox')); + await userEvent.type(screen.getByRole('textbox'), ' '); await userEvent.click(screen.getByTestId('popover-outside')); expect(onSave).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index 9414b6df..bd7fcafc 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -933,10 +933,13 @@ describe('PhraseBox', () => { renderBox(); const box = document.querySelector('[data-phrase-box="true"]'); expect(box).not.toBeNull(); - // Focus the box container, then press Enter → should focus the first input. + // Focus the box container, then press Enter → the keydown handler forwards focus to the first + // gloss input. Asserting toHaveFocus makes the test fail if the Enter branch of + // focusFirstGlossOnSelfKeyDown is broken or removed. if (box instanceof HTMLElement) box.focus(); await userEvent.keyboard('{Enter}'); - // No throw = pass (jsdom doesn't fire input focus in this setup, but the handler runs). + + expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).toHaveFocus(); }); it('pops out a middle token from a 3+ token phrase in view mode (updatePhrase)', async () => { @@ -1049,19 +1052,6 @@ describe('PhraseBox', () => { expect(screen.getByRole('button', { name: 'Remove World' })).toBeInTheDocument(); }); - it('pops out a token from a 3-token phrase by deleting when only 1 would remain (edge case)', async () => { - // Actually a 3-token phrase removes a middle token to leave 2 (≥2), so updatePhrase is called. - // For deletePhrase to be called, we need to go from 2 tokens to ≤1. - // The onRemove is only wired for middle tokens when length > 2. So we simulate handleViewPopOut - // via the phraseLink having exactly 2 tokens (which means the mock won't show Remove button). - // Instead, let's test the deletePhrase path by using a 3-token phrase removing to leave 1 token - // by mocking phraseLink.tokens to have 2 entries while tokens prop has 3, and removing the first. - // Actually, the easiest way is to test through phraseLink with 2 tokens where the condition - // doesn't show the Remove button. Skip this path via v8 ignore instead. - // This test just documents the behavior. - expect(true).toBe(true); - }); - it('writes phrase gloss on blur when draft differs from committed', async () => { mockUsePhraseGloss.mockReturnValue(''); const dispatchSpy = jest.fn(); @@ -1075,12 +1065,14 @@ describe('PhraseBox', () => { }); it('ignores non-Enter/Space keys on the box container', async () => { - const setPhraseMode = jest.fn(); - renderBox(, { setPhraseMode }); + renderBox(); const box = document.querySelector('[data-phrase-box="true"]'); if (box instanceof HTMLElement) box.focus(); - await userEvent.keyboard('{Tab}'); - expect(setPhraseMode).not.toHaveBeenCalled(); + // ArrowRight is neither Enter nor Space and does not move focus by default, so + // focusFirstGlossOnSelfKeyDown must return early without focusing the first gloss input. If the + // Enter/Space guard were dropped, this key would forward focus to the gloss input and fail here. + await userEvent.keyboard('{ArrowRight}'); + expect(screen.getByRole('textbox', { name: 'Gloss for Hello' })).not.toHaveFocus(); }); it('renders disabled style for a token in the wrong segment during edit mode', () => { diff --git a/src/__tests__/components/PhraseStripParts.test.tsx b/src/__tests__/components/PhraseStripParts.test.tsx index 9eab0dfb..e548ecbd 100644 --- a/src/__tests__/components/PhraseStripParts.test.tsx +++ b/src/__tests__/components/PhraseStripParts.test.tsx @@ -22,7 +22,9 @@ import { makePhraseLink, makePhraseStripContext, makeWordToken } from '../test-h jest.mock('../../components/TokenLinkIcon', () => ({ __esModule: true, - default: () => , + default: ({ isPhraseRevealed }: Readonly<{ isPhraseRevealed: boolean }>) => ( + + ), })); jest.mock('../../components/TokenChip', () => ({ @@ -155,12 +157,29 @@ describe('PhraseSlot', () => { punctuationBetween: [], }; const slot: LinkSlot = { prevGroup, nextGroup, punctuation: [] }; - // PhrasedRevealed means the unlink button is shown — but TokenLinkIcon is mocked to undefined, - // so just check no errors are thrown when hoveredPhraseId matches. - const { container } = render( - withProvider(), - ); - expect(container.firstChild).not.toBeNull(); + // phraseRevealed (source lines 70-73) flows into TokenLinkIcon as isPhraseRevealed; the mock + // surfaces it as data-phrase-revealed so we can assert the computation directly. + render(withProvider()); + expect(screen.getByTestId('link-icon')).toHaveAttribute('data-phrase-revealed', 'true'); + }); + + it('does not set phraseRevealed when the hovered phrase differs from the neighbors', () => { + const link = makePhraseLink('p1', ['tok-a', 'tok-b']); + const prevGroup: TokenGroup = { + tokens: [makeWordToken('tok-a')], + phraseLink: link, + firstIndex: 0, + punctuationBetween: [], + }; + const nextGroup: TokenGroup = { + tokens: [makeWordToken('tok-b')], + phraseLink: link, + firstIndex: 1, + punctuationBetween: [], + }; + const slot: LinkSlot = { prevGroup, nextGroup, punctuation: [] }; + render(withProvider()); + expect(screen.getByTestId('link-icon')).toHaveAttribute('data-phrase-revealed', 'false'); }); it('sets phraseRevealed via focusedPhraseId when both neighbors are in the same focused phrase', () => { @@ -185,12 +204,14 @@ describe('PhraseSlot', () => { focusedSegmentId: 'seg-1', focusedPhraseId: 'p1', }; - const { container } = render( + // With no hover, the only way phraseRevealed becomes true is the focus.focusedPhraseId branch + // (source line 73); the mock exposes it via data-phrase-revealed. + render( withProvider( , ), ); - expect(container.firstChild).not.toBeNull(); + expect(screen.getByTestId('link-icon')).toHaveAttribute('data-phrase-revealed', 'true'); }); it('renders the link icon when hideInactiveLinkButtons is off', () => { @@ -228,7 +249,6 @@ describe('PhraseSlot', () => { ); // Icon stays mounted but invisible (opacity:0 hides it while the min-height preserves layout space). const icon = screen.getByTestId('link-icon'); - expect(icon.parentElement?.style.visibility).toBeFalsy(); expect(icon.parentElement?.style.opacity).toBe('0'); }); @@ -274,7 +294,6 @@ describe('PhraseSlot', () => { ); // Icon stays mounted but invisible (opacity:0 hides it while the min-height preserves layout space). const icon = screen.getByTestId('link-icon'); - expect(icon.parentElement?.style.visibility).toBeFalsy(); expect(icon.parentElement?.style.opacity).toBe('0'); }); }); diff --git a/src/__tests__/components/SegmentView.test.tsx b/src/__tests__/components/SegmentView.test.tsx index 29a771e0..a669ea3e 100644 --- a/src/__tests__/components/SegmentView.test.tsx +++ b/src/__tests__/components/SegmentView.test.tsx @@ -7,6 +7,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { PhraseAnalysisLink, ScriptureRef, Segment, Token } from 'interlinearizer'; import type { ReactNode } from 'react'; +import type { SlotFocusInfo } from '../../types/token-layout'; import type { PhraseDispatch } from '../../components/AnalysisStore'; import { LINK_SLOT_TRANSITION_MS } from '../../components/PhraseStripParts'; import { SegmentView } from '../../components/SegmentView'; @@ -58,6 +59,7 @@ jest.mock('../../components/AnalysisStore', () => ({ // SegmentView's tests don't redundantly re-exercise the hook's internals; the view only forwards its // handlers, which a no-op stub satisfies. const mockCandidateTokenRefs = { current: new Set() }; +const mockSplitFreeTokenRefs = { current: new Set() }; jest.mock('../../hooks/usePhraseHoverState', () => ({ __esModule: true, usePhraseHoverState: () => ({ @@ -65,7 +67,7 @@ jest.mock('../../hooks/usePhraseHoverState', () => ({ setHoveredGroupKey: () => {}, candidateTokenRefs: mockCandidateTokenRefs.current, setCandidateTokenRefs: () => {}, - splitFreeTokenRefs: new Set(), + splitFreeTokenRefs: mockSplitFreeTokenRefs.current, handleSplitHoverChange: () => {}, handleHoverSplitFreeTokens: () => {}, clearAll: () => {}, @@ -76,17 +78,41 @@ jest.mock('../../components/TokenChip'); jest.mock('../../components/TokenLinkIcon', () => ({ __esModule: true, - default: () => undefined, + // Surface the slot's focus side and its neighboring token refs so tests can assert which side of + // each slot SegmentView decided the focused group falls on (the focusedSideIsPrevByUnit walk). + default: ({ + slotFocus, + prevToken, + nextToken, + }: Readonly<{ + slotFocus: SlotFocusInfo; + prevToken: { ref: string } | undefined; + nextToken: { ref: string } | undefined; + }>) => ( + + ), })); jest.mock('../../components/ArcOverlay', () => ({ __esModule: true, default: ({ onArcSplit, - }: Readonly<{ onArcSplit: (phraseId: string, splitAfterTokenRef: string) => void }>) => ( + candidatePhraseIds, + }: Readonly<{ + onArcSplit: (phraseId: string, splitAfterTokenRef: string) => void; + candidatePhraseIds: ReadonlySet; + }>) => ( + + + + ); +} diff --git a/src/components/modals/CreateProjectModal.tsx b/src/components/modals/CreateProjectModal.tsx index 35781efe..4b592336 100644 --- a/src/components/modals/CreateProjectModal.tsx +++ b/src/components/modals/CreateProjectModal.tsx @@ -1,6 +1,8 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { Button } from 'platform-bible-react'; import { useState, useCallback } from 'react'; +import { parseLanguageTags } from '../../utils/language-tags'; +import { ModalShell } from './ModalShell'; /** Localized string keys used by {@link CreateProjectModal}. */ const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [ @@ -68,10 +70,7 @@ export function CreateProjectModal({ * {@link onCreateDraft}. */ const handleSubmit = useCallback(() => { - const parsedLanguages = analysisLanguages - .split(',') - .map((t) => t.trim()) - .filter((t) => t.length > 0); + const parsedLanguages = parseLanguageTags(analysisLanguages); const normalizedAnalysisLanguages = parsedLanguages.length > 0 ? parsedLanguages : ['und']; onCreateDraft({ analysisLanguages: normalizedAnalysisLanguages, @@ -83,56 +82,50 @@ export function CreateProjectModal({ /* v8 ignore next */ if (stringsLoading) return undefined; return ( -
- -

- {localizedStrings['%interlinearizer_modal_create_title%']} -

- - setName(e.target.value)} - placeholder={localizedStrings['%interlinearizer_modal_create_name_placeholder%']} - /> - -