Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export function PopoverAnchor({
* - An Escape keydown anywhere inside the content invokes `onEscapeKeyDown`.
* - A sentinel `data-testid="popover-outside"` button invokes `onInteractOutside` on click,
* simulating a pointer interaction outside the popover.
* - A sentinel `data-testid="popover-close"` button invokes `onCloseAutoFocus` on click,
* simulating Radix's focus-restoration event fired as the popover closes.
*
* @param props - Component props.
* @param props.children - Panel content.
Expand All @@ -371,6 +373,8 @@ export function PopoverAnchor({
* in `detail.originalEvent` when the sentinel outside button is clicked, matching the shape of
* Radix's `PointerDownOutsideEvent`.
* @param props.onOpenAutoFocus - Called once on mount with a plain `Event`.
* @param props.onCloseAutoFocus - Called with a plain `Event` when the sentinel close button is
* clicked, mirroring Radix's close-time focus-restoration event.
* @param props.onClick - Click handler forwarded to the div.
* @param props.onMouseDown - Mouse-down handler forwarded to the div.
* @returns A `<div data-testid="popover-content">` with the panel content and sentinel controls.
Expand All @@ -381,6 +385,7 @@ export function PopoverContent({
onEscapeKeyDown,
onInteractOutside,
onOpenAutoFocus,
onCloseAutoFocus,
onClick,
onMouseDown,
}: Readonly<{
Expand All @@ -391,6 +396,7 @@ export function PopoverContent({
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onInteractOutside?: (event: CustomEvent) => void;
onOpenAutoFocus?: (event: Event) => void;
onCloseAutoFocus?: (event: Event) => void;
onClick?: MouseEventHandler<HTMLDivElement>;
onMouseDown?: MouseEventHandler<HTMLDivElement>;
}>): ReactElement {
Expand Down Expand Up @@ -426,6 +432,15 @@ export function PopoverContent({
outside
</button>
)}
{onCloseAutoFocus && (
<button
data-testid="popover-close"
type="button"
onClick={() => onCloseAutoFocus(new Event('closeAutoFocus'))}
>
close
</button>
)}
</div>
);
}
Expand Down
7 changes: 7 additions & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
"%interlinearizer_viewOption_showMorphology%": "Show morphology",
"%interlinearizer_projectSettings_showMorphology%": "Show Morphology",
"%interlinearizer_projectSettings_showMorphologyDescription%": "Display morpheme breakdown and per-morpheme glosses beneath each word token",
"%interlinearizer_morphemeEditor_splitLabel%": "Split into morphemes",
"%interlinearizer_morphemeEditor_delete%": "Delete",
"%interlinearizer_morphemeEditor_cancel%": "Cancel",
"%interlinearizer_morphemeEditor_done%": "Done",
"%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}",
"%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}",
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",

"%interlinearizer_modal_create_title%": "Create Interlinear Project",
Expand Down
192 changes: 137 additions & 55 deletions src/__tests__/components/MorphemeEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,72 @@
/// <reference types="jest" />
/// <reference types="@testing-library/jest-dom" />

import { useLocalizedStrings } from '@papi/frontend/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'react';
import * as AnalysisStore from '../../components/AnalysisStore';
import { MorphemeBreakdownPopover, MorphemeGlossInput } from '../../components/MorphemeEditor';

jest.mock('../../components/AnalysisStore');

const LOCALIZED = {
'%interlinearizer_morphemeEditor_splitLabel%': 'Split into morphemes',
'%interlinearizer_morphemeEditor_delete%': 'Delete',
'%interlinearizer_morphemeEditor_cancel%': 'Cancel',
'%interlinearizer_morphemeEditor_done%': 'Done',
'%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}',
};

beforeEach(() => {
jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]);
});

/**
* Renders {@link MorphemeBreakdownPopover} with the two structural props (`surfaceText`,
* `glossInputId`) defaulted so each test only supplies what it asserts on.
*
* @param props - Overrides merged over the defaults; callers pass their own `onSave`/`onClose`
* spies.
* @returns The render result.
*/
function renderPopover(props: Partial<ComponentProps<typeof MorphemeBreakdownPopover>> = {}) {
return render(
<MorphemeBreakdownPopover
glossInputId="gloss-1"
initialValue="test"
onClose={jest.fn()}
onSave={jest.fn()}
surfaceText="word"
{...props}
/>,
);
}

describe('MorphemeBreakdownPopover', () => {
it('renders with the initial value pre-filled', () => {
render(
<MorphemeBreakdownPopover
initialValue="un- believe -able"
onSave={jest.fn()}
onClose={jest.fn()}
/>,
);
renderPopover({ initialValue: 'un- believe -able' });
const input = screen.getByRole('textbox');
expect(input).toHaveValue('un- believe -able');
});

it('auto-focuses and selects the input on mount', () => {
render(<MorphemeBreakdownPopover initialValue="word" onSave={jest.fn()} onClose={jest.fn()} />);
renderPopover({ initialValue: 'word' });
expect(screen.getByRole('textbox')).toHaveFocus();
});

it('calls onSave and onClose when Done button is clicked', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
render(
<MorphemeBreakdownPopover initialValue="un- believe" onSave={onSave} onClose={onClose} />,
);
renderPopover({ initialValue: 'un- believe', onSave, onClose, surfaceText: 'unbelieve' });
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
expect(onSave).toHaveBeenCalledWith('un- believe');
expect(onClose).toHaveBeenCalledTimes(1);
});

it('calls onSave with the edited value', async () => {
const onSave = jest.fn();
render(<MorphemeBreakdownPopover initialValue="word" onSave={onSave} onClose={jest.fn()} />);
renderPopover({ initialValue: 'word', onSave, surfaceText: 'word' });
await userEvent.clear(screen.getByRole('textbox'));
await userEvent.type(screen.getByRole('textbox'), 'wor -d');
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
Expand All @@ -50,47 +77,44 @@ describe('MorphemeBreakdownPopover', () => {
it('does not save when Done is clicked with unchanged text and an existing breakdown', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
render(
<MorphemeBreakdownPopover
initialValue="un- believe"
onSave={onSave}
onClose={onClose}
onDelete={jest.fn()}
/>,
);
renderPopover({
initialValue: 'un- believe',
onSave,
onClose,
onDelete: jest.fn(),
surfaceText: 'unbelieve',
});
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
expect(onSave).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});

it('saves when Done is clicked with edited text and an existing breakdown', async () => {
const onSave = jest.fn();
render(
<MorphemeBreakdownPopover
initialValue="un- believe"
onSave={onSave}
onClose={jest.fn()}
onDelete={jest.fn()}
/>,
);
renderPopover({
initialValue: 'un- believe',
onSave,
onDelete: jest.fn(),
surfaceText: 'unbelieve',
});
await userEvent.type(screen.getByRole('textbox'), ' -r');
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
expect(onSave).toHaveBeenCalledWith('un- believe -r');
});

it('commits on Enter key', async () => {
it('commits a multi-morpheme breakdown on Enter key', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={onClose} />);
renderPopover({ initialValue: 'te -st', onSave, onClose, surfaceText: 'test' });
await userEvent.keyboard('{Enter}');
expect(onSave).toHaveBeenCalledWith('test');
expect(onSave).toHaveBeenCalledWith('te -st');
expect(onClose).toHaveBeenCalledTimes(1);
});

it('dismisses without saving on Escape key', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={onClose} />);
renderPopover({ initialValue: 'te -st', onSave, onClose, surfaceText: 'test' });
await userEvent.keyboard('{Escape}');
expect(onSave).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
Expand All @@ -99,7 +123,7 @@ describe('MorphemeBreakdownPopover', () => {
it('dismisses without saving when Cancel button is clicked', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={onClose} />);
renderPopover({ initialValue: 'te -st', onSave, onClose, surfaceText: 'test' });
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onSave).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
Expand All @@ -108,7 +132,7 @@ describe('MorphemeBreakdownPopover', () => {
it('closes without saving when interacting outside with unchanged text', async () => {
const onSave = jest.fn();
const onClose = jest.fn();
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={onClose} />);
renderPopover({ initialValue: 'te -st', onSave, onClose, surfaceText: 'test' });
// The platform-bible-react mock exposes a sentinel button that fires onInteractOutside,
// simulating a pointer interaction outside the popover.
await userEvent.click(screen.getByTestId('popover-outside'));
Expand All @@ -118,22 +142,22 @@ describe('MorphemeBreakdownPopover', () => {

it('saves on outside interaction when the text was edited', async () => {
const onSave = jest.fn();
render(<MorphemeBreakdownPopover initialValue="test" onSave={onSave} onClose={jest.fn()} />);
renderPopover({ initialValue: 'test', onSave, surfaceText: 'whole' });
await userEvent.type(screen.getByRole('textbox'), ' -er');
await userEvent.click(screen.getByTestId('popover-outside'));
expect(onSave).toHaveBeenCalledWith('test -er');
});

it('does not save on outside interaction when the input is only whitespace', async () => {
const onSave = jest.fn();
render(<MorphemeBreakdownPopover initialValue=" " onSave={onSave} onClose={jest.fn()} />);
renderPopover({ initialValue: ' ', onSave });
await userEvent.click(screen.getByTestId('popover-outside'));
expect(onSave).not.toHaveBeenCalled();
});

it('does not dismiss when clicking inside the popover panel', async () => {
const onClose = jest.fn();
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={onClose} />);
renderPopover({ onClose });
const label = screen.getByText('Split into morphemes');
await userEvent.click(label);
expect(onClose).not.toHaveBeenCalled();
Expand All @@ -145,7 +169,13 @@ describe('MorphemeBreakdownPopover', () => {
const ancestorClick = jest.fn();
render(
<div role="presentation" onClick={ancestorClick}>
<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />
<MorphemeBreakdownPopover
glossInputId="gloss-1"
initialValue="test"
onClose={jest.fn()}
onSave={jest.fn()}
surfaceText="word"
/>
</div>,
);
await userEvent.click(screen.getByText('Split into morphemes'));
Expand All @@ -158,44 +188,59 @@ describe('MorphemeBreakdownPopover', () => {
const ancestorMouseDown = jest.fn();
render(
<div role="presentation" onMouseDown={ancestorMouseDown}>
<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />
<MorphemeBreakdownPopover
glossInputId="gloss-1"
initialValue="test"
onClose={jest.fn()}
onSave={jest.fn()}
surfaceText="word"
/>
</div>,
);
fireEvent.mouseDown(screen.getByText('Split into morphemes'));
expect(ancestorMouseDown).not.toHaveBeenCalled();
});

it('does not call onSave when the input is empty', async () => {
it('disables Done when the input is only whitespace', () => {
renderPopover({ initialValue: ' ' });
expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled();
});

it('does not save whitespace on Enter', async () => {
const onSave = jest.fn();
render(<MorphemeBreakdownPopover initialValue="" onSave={onSave} onClose={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
renderPopover({ initialValue: ' ', onSave, surfaceText: 'word' });
await userEvent.keyboard('{Enter}');
expect(onSave).not.toHaveBeenCalled();
});

it('does not call onSave when the input is only whitespace', async () => {
it('disables Done for a breakdown that is just the whole word as one morpheme', () => {
renderPopover({ initialValue: 'word', surfaceText: 'word' });
expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled();
});

it('does not save a breakdown that is just the whole word as one morpheme on Enter', async () => {
const onSave = jest.fn();
render(<MorphemeBreakdownPopover initialValue=" " onSave={onSave} onClose={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
renderPopover({ initialValue: 'word', onSave, surfaceText: 'word' });
await userEvent.keyboard('{Enter}');
expect(onSave).not.toHaveBeenCalled();
});

it('does not render a Delete button when onDelete is not provided', () => {
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />);
renderPopover();
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});

it('calls onDelete and onClose without saving when Delete is clicked', async () => {
const onDelete = jest.fn();
const onSave = jest.fn();
const onClose = jest.fn();
render(
<MorphemeBreakdownPopover
initialValue="un- believe"
onSave={onSave}
onClose={onClose}
onDelete={onDelete}
/>,
);
renderPopover({
initialValue: 'un- believe',
onSave,
onClose,
onDelete,
surfaceText: 'unbelieve',
});
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
Expand All @@ -205,10 +250,47 @@ describe('MorphemeBreakdownPopover', () => {
it('renders inside the popover content panel', () => {
// Positioning, portaling, and flipping are owned by the platform-bible-react Popover; this
// only verifies the editor renders as the popover's content.
render(<MorphemeBreakdownPopover initialValue="test" onSave={jest.fn()} onClose={jest.fn()} />);
renderPopover();
const content = screen.getByTestId('popover-content');
expect(content).toContainElement(screen.getByText('Split into morphemes'));
});

it('focuses the first morpheme gloss field of the chip when the popover closes', async () => {
// The chip label holds the morpheme gloss inputs before the token gloss input; on close, focus
// should land on the first morpheme gloss, scoped to this token's label via glossInputId.
render(
<label>
<input aria-label="morpheme gloss" data-morpheme-gloss="true" />
<input aria-label="token gloss" id="gloss-1" />
<MorphemeBreakdownPopover
glossInputId="gloss-1"
initialValue="word"
onClose={jest.fn()}
onSave={jest.fn()}
surfaceText="word"
/>
</label>,
);
await userEvent.click(screen.getByTestId('popover-close'));
expect(screen.getByRole('textbox', { name: 'morpheme gloss' })).toHaveFocus();
});

it('falls back to the token gloss input on close when the chip has no morpheme gloss field', async () => {
render(
<label>
<input aria-label="token gloss" id="gloss-1" />
<MorphemeBreakdownPopover
glossInputId="gloss-1"
initialValue="word"
onClose={jest.fn()}
onSave={jest.fn()}
surfaceText="word"
/>
</label>,
);
await userEvent.click(screen.getByTestId('popover-close'));
expect(screen.getByRole('textbox', { name: 'token gloss' })).toHaveFocus();
});
});

describe('MorphemeGlossInput', () => {
Expand Down
Loading