Skip to content
Open
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
25 changes: 21 additions & 4 deletions __mocks__/papi-frontend-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,34 @@ const useProjectSetting = jest
false,
]);

/**
* Records already built, keyed by the requested key list, so a repeated call hands back the same
* object. The real hook keeps resolved data in state and so is stable across an unchanged render;
* rebuilding the record every call would defeat any memo keyed on it and make a render-count
* assertion a measurement of this mock rather than of the component.
*/
const localizedStringRecords = new Map<string, Record<string, string>>();

/**
* Mock for `useLocalizedStrings`. Maps each requested key to itself so tests receive a
* predictable `Record<string, string>` without a real localization service.
*
* @returns Tuple of `[record, isLoading]` where every key maps to itself and `isLoading` is
* `false`.
*/
const useLocalizedStrings = jest.fn().mockImplementation((keys: string[]) => [
Array.isArray(keys) ? keys.reduce<Record<string, string>>((acc, k) => { acc[k] = k; return acc; }, {}) : {},
false,
]);
const useLocalizedStrings = jest.fn().mockImplementation((keys: string[]) => {
if (!Array.isArray(keys)) return [{}, false];
const cacheKey = keys.join(' ');
let record = localizedStringRecords.get(cacheKey);
if (!record) {
record = keys.reduce<Record<string, string>>((acc, k) => {
acc[k] = k;
return acc;
}, {});
localizedStringRecords.set(cacheKey, record);
}
return [record, false];
});

/**
* Mock for `useSetting`. Returns `[defaultState, jest.fn(), jest.fn(), false]`, passing
Expand Down
211 changes: 205 additions & 6 deletions src/__tests__/components/ContinuousView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,21 +300,29 @@ function requiredProps(
let resizeObserverInstances: TrackingResizeObserver[] = [];

/**
* A ResizeObserver test double that records its callback and disconnect state and appends itself to
* {@link resizeObserverInstances}, so a test can fire a simulated late content reflow and assert
* whether the active observer was disconnected. Module-scoped (rather than an inline class per
* test) so the file stays under `max-classes-per-file`.
* A ResizeObserver test double that records what it was pointed at, its callback, and its
* disconnect state, and appends itself to {@link resizeObserverInstances}, so a test can fire a
* simulated late content reflow and assert whether the active observer was disconnected.
* Module-scoped (rather than an inline class per test) so the file stays under
* `max-classes-per-file`.
*/
class TrackingResizeObserver implements ResizeObserver {
/** Whether {@link disconnect} has been called on this instance. */
disconnected = false;

/**
* Elements this instance was pointed at. The view runs several observers at once, so a test picks
* one by the element it watches rather than by creation order.
*/
targets: Element[] = [];

constructor(public callback: ResizeObserverCallback) {
resizeObserverInstances.push(this);
}

// eslint-disable-next-line @typescript-eslint/class-methods-use-this
observe() {}
observe(target: Element) {
this.targets.push(target);
}

// eslint-disable-next-line @typescript-eslint/class-methods-use-this
unobserve() {}
Expand Down Expand Up @@ -1405,6 +1413,197 @@ describe('ContinuousView phrase window', () => {
// tok-299 is well outside the rendered phrase window.
expect(screen.queryByText('word299')).not.toBeInTheDocument();
});

/** Links two tokens far enough apart that only one of them falls inside the starting window. */
function linkFarApartTokens(): void {
const phraseLink: PhraseAnalysisLink = {
...FIXTURE_STAMPS,
analysisId: 'phrase-far',
status: 'approved',
tokens: [
{ tokenRef: 'large-tok-150', surfaceText: 'word150' },
{ tokenRef: 'large-tok-190', surfaceText: 'word190' },
],
};
phraseLinkMap.set('large-tok-150', phraseLink);
phraseLinkMap.set('large-tok-190', phraseLink);
}

it('mounts the far fragment of a discontiguous phrase the window touches', () => {
// An arc runs between two mounted phrase boxes, so a fragment left outside the window would
// take the whole arc with it and leave the visible fragment with no phrase cue at all.
linkFarApartTokens();
const book = makeLargeBook(300);
render(
<ContinuousView {...requiredProps(book, { focusedTokenRef: 'large-tok-150' })} />,
withAnalysisStore,
);

expect(screen.getByText('word190')).toBeInTheDocument();
});

it('widens no further than the phrase span it is covering', () => {
linkFarApartTokens();
const book = makeLargeBook(300);
render(
<ContinuousView {...requiredProps(book, { focusedTokenRef: 'large-tok-150' })} />,
withAnalysisStore,
);

expect(screen.queryByText('word200')).not.toBeInTheDocument();
});

it('leaves an unlinked token at the same distance outside the window', () => {
const book = makeLargeBook(300);
render(
<ContinuousView {...requiredProps(book, { focusedTokenRef: 'large-tok-150' })} />,
withAnalysisStore,
);

expect(screen.queryByText('word190')).not.toBeInTheDocument();
});

it('re-centers the focused group when a viewport resize widens the window', () => {
// The focus never moves here, so no focus-keyed centering path fires; without the window-keyed
// one the groups mounting ahead of the focus carry it off the strip.
const originalResizeObserver = global.ResizeObserver;
resizeObserverInstances = [];
global.ResizeObserver = TrackingResizeObserver;

try {
const book = makeLargeBook(300);
render(
<ContinuousView {...requiredProps(book, { focusedTokenRef: 'large-tok-150' })} />,
withAnalysisStore,
);

// jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide
// enough to ask for more groups, over groups spaced a fixed pitch apart.
const viewport = screen.getByTestId('strip-scroll-viewport');
const stripRow = screen.getByTestId('token-strip');
const mountedGroups = () => stripRow.querySelectorAll('[data-phrase-group="true"]').length;
Object.defineProperty(viewport, 'clientWidth', { configurable: true, value: 2000 });
stripRow.querySelectorAll('[data-phrase-group="true"]').forEach((group, index) => {
group.getBoundingClientRect = () => ({
left: index * 100,
right: index * 100,
top: 0,
bottom: 0,
width: 0,
height: 0,
x: index * 100,
y: 0,
toJSON: () => ({}),
});
});

const groupsBeforeResize = mountedGroups();
scrollIntoViewMock.mockClear();

// Only the render window observes the clipping viewport; the centering hold and the arc pass
// watch content elements, so firing this one exercises the window path alone.
const windowObserver = resizeObserverInstances.find((o) => o.targets.includes(viewport));
if (!windowObserver) throw new Error('Expected the render window to observe the viewport');
act(() => {
windowObserver.callback([], { disconnect() {}, observe() {}, unobserve() {} });
});

expect(mountedGroups()).toBeGreaterThan(groupsBeforeResize);
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'auto',
block: 'nearest',
inline: 'center',
});
} finally {
global.ResizeObserver = originalResizeObserver;
}
});

it('stops holding the pre-resize group centered once the reader navigates away', () => {
// A navigation slides the window, which is itself a content resize — so a hold left over from
// the window change restarts its loop, instant-scrolls back to the group the reader just left,
// and parks the strip there.
const originalResizeObserver = global.ResizeObserver;
resizeObserverInstances = [];
global.ResizeObserver = TrackingResizeObserver;
const stubObserver = { disconnect() {}, observe() {}, unobserve() {} };

try {
const book = makeLargeBook(300);
const props = requiredProps(book, { focusedTokenRef: 'large-tok-150' });
const { rerender } = render(<ContinuousView {...props} />, withAnalysisStore);

// jsdom lays nothing out, so the geometry the window measures is supplied: a viewport wide
// enough to ask for more groups, over groups spaced a fixed pitch apart.
const viewport = screen.getByTestId('strip-scroll-viewport');
const stripRow = screen.getByTestId('token-strip');
Object.defineProperty(viewport, 'clientWidth', { configurable: true, value: 2000 });
const layOutGroups = () => {
stripRow.querySelectorAll('[data-phrase-group="true"]').forEach((group, index) => {
group.getBoundingClientRect = () => ({
left: index * 100,
right: index * 100,
top: 0,
bottom: 0,
width: 0,
height: 0,
x: index * 100,
y: 0,
toJSON: () => ({}),
});
});
};
layOutGroups();

act(() => {
jest.useFakeTimers();
});
try {
// Widen the window, which arms the hold on the currently-focused group.
const windowObserver = resizeObserverInstances.find((o) => o.targets.includes(viewport));
if (!windowObserver) throw new Error('Expected the render window to observe the viewport');
act(() => {
windowObserver.callback([], stubObserver);
});
layOutGroups();

// Navigate one phrase forward while that hold is still alive, echoing the new ref back the
// way the parent does so the strip treats it as internal navigation.
fireEvent.click(
screen.getByRole('button', { name: '%interlinearizer_continuousView_nextToken%' }),
);
const emitted = props.onFocusedTokenRefChange.mock.calls.at(-1)?.[0];
const nextRef = typeof emitted === 'string' ? emitted : undefined;
expect(nextRef).toBe('large-tok-151');
rerender(<ContinuousView {...{ ...props, focusedTokenRef: nextRef }} />);

scrollIntoViewMock.mockClear();
// Report the window slide's own reflow, then run out the frames a restarted hold would use.
resizeObserverInstances
.filter((o) => o.targets.includes(stripRow) && !o.disconnected)
.forEach((observer) => {
act(() => {
observer.callback([], stubObserver);
});
});
act(() => {
// Comfortably past the hold's quiet period, so every frame it would use has run.
jest.advanceTimersByTime(300);
});

const centeredGroups = scrollIntoViewMock.mock.instances.map((el: unknown) =>
el instanceof HTMLElement ? el.textContent : undefined,
);
expect(centeredGroups).not.toContain('word150');
} finally {
act(() => {
jest.useRealTimers();
});
}
} finally {
global.ResizeObserver = originalResizeObserver;
}
});
});

describe('ContinuousView phrase grouping', () => {
Expand Down
24 changes: 16 additions & 8 deletions src/__tests__/components/MorphemeBox.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
/// <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 { MorphemeAnalysis } from 'interlinearizer';
import * as AnalysisStore from '../../components/AnalysisStore';
import { MorphemeBox, MorphemeGlossInput } from '../../components/MorphemeBox';
import { TOKEN_CHIP_LABEL_KEYS } from '../../components/PhraseStripContext';
import { makeWordToken } from '../test-helpers';

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

const LOCALIZED = {
'%interlinearizer_tokenChip_editMorphemes%': 'Edit morpheme breakdown for {token}',
'%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}',
// Resolved templates rather than the bare keys: the tests below target one morpheme's input among
// several, which only the substituted `{form}` distinguishes.
const LABELS = {
...TOKEN_CHIP_LABEL_KEYS,
editMorphemes: 'Edit morpheme breakdown for {token}',
morphemeGloss: 'Gloss for morpheme {form}',
};
Comment thread
alex-rawlings-yyc marked this conversation as resolved.

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

const WORD_TOKEN = makeWordToken('GEN 1:1:0', 'hello');

const MORPHEMES: MorphemeAnalysis[] = [
Expand All @@ -36,6 +35,7 @@ function renderBox(props: Partial<Parameters<typeof MorphemeBox>[0]> = {}) {
<MorphemeBox
analysisLanguage="en"
disabled={false}
labels={LABELS}
morphemes={MORPHEMES}
onEditBreakdown={jest.fn()}
onGlossFocus={jest.fn()}
Expand Down Expand Up @@ -138,6 +138,7 @@ describe('MorphemeBox', () => {
<MorphemeBox
analysisLanguage="en"
disabled={false}
labels={LABELS}
morphemes={MORPHEMES}
onEditBreakdown={jest.fn()}
onGlossFocus={jest.fn()}
Expand All @@ -162,6 +163,7 @@ describe('MorphemeBox', () => {
<MorphemeBox
analysisLanguage="en"
disabled={false}
labels={LABELS}
morphemes={MORPHEMES}
onEditBreakdown={jest.fn()}
onGlossFocus={jest.fn()}
Expand Down Expand Up @@ -219,6 +221,7 @@ describe('MorphemeGlossInput', () => {
it('renders an empty input when no gloss exists', () => {
render(
<MorphemeGlossInput
glossLabelTemplate={LABELS.morphemeGloss}
analysisLanguage="und"
column={1}
disabled={false}
Expand All @@ -233,6 +236,7 @@ describe('MorphemeGlossInput', () => {
it('renders the existing gloss value', () => {
render(
<MorphemeGlossInput
glossLabelTemplate={LABELS.morphemeGloss}
analysisLanguage="und"
column={1}
disabled={false}
Expand All @@ -250,6 +254,7 @@ describe('MorphemeGlossInput', () => {

render(
<MorphemeGlossInput
glossLabelTemplate={LABELS.morphemeGloss}
analysisLanguage="und"
column={1}
disabled={false}
Expand All @@ -269,6 +274,7 @@ describe('MorphemeGlossInput', () => {

render(
<MorphemeGlossInput
glossLabelTemplate={LABELS.morphemeGloss}
analysisLanguage="und"
column={1}
disabled={false}
Expand All @@ -286,6 +292,7 @@ describe('MorphemeGlossInput', () => {
const onFocus = jest.fn();
render(
<MorphemeGlossInput
glossLabelTemplate={LABELS.morphemeGloss}
analysisLanguage="und"
column={1}
disabled={false}
Expand All @@ -304,6 +311,7 @@ describe('MorphemeGlossInput', () => {

render(
<MorphemeGlossInput
glossLabelTemplate={LABELS.morphemeGloss}
analysisLanguage="und"
column={1}
disabled
Expand Down
Loading
Loading