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
1 change: 1 addition & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
"%interlinearizer_continuousView_nextToken%": "Next word",
"%interlinearizer_continuousView_returnToFocus%": "Scroll to focused phrase",
"%interlinearizer_viewOptions_label%": "View options",
"%interlinearizer_analysisLanguage_label%": "Analysis language",
"%interlinearizer_loading%": "Loading…",

"%interlinearizer_modal_create_title%": "Create Interlinear Project",
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/components/AnalysisCatalogPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,60 @@ describe('AnalysisCatalogPanel', () => {
expect(list.scrollTop).toBe(0);
});

/** The panel over {@link MANY}, read in `analysisLanguage`. */
function panelIn(analysisLanguage: string) {
return (
<PanelProviders overrides={{ analysis: MANY, analysisLanguage }}>
<AnalysisCatalogPanel
currentBook="GEN"
headingPlacements={NO_HEADINGS}
liveSurfaceText={undriftedText}
onClose={() => {}}
onShowConcordance={() => {}}
showMorphology
sourceLanguageTag="el"
/>
</PanelProviders>
);
}

it('leaves the scroll where it is when the analysis language changes under a usage sort', () => {
const { rerender } = render(panelIn('en'));
const list = rowList();
list.scrollTop = 500;

rerender(panelIn('fr'));

expect(list.scrollTop).toBe(500);
});

it('returns the list to its top when the analysis language changes under a gloss sort', async () => {
const { rerender } = render(panelIn('en'));
await userEvent.click(screen.getByTestId('catalog-sort-gloss'));
const list = rowList();
list.scrollTop = 500;

rerender(panelIn('fr'));

expect(list.scrollTop).toBe(0);
});

it('returns the list to its top when the analysis language changes under the missing-gloss filter', async () => {
const { rerender } = render(panelIn('en'));
await openFilters();
await userEvent.click(
screen.getByRole('checkbox', {
name: '%interlinearizer_analysisCatalog_filter_missingGloss%',
}),
);
const list = rowList();
list.scrollTop = 500;

rerender(panelIn('fr'));

expect(list.scrollTop).toBe(0);
});

it('keeps the window where it is when the analysis changes under an unchanged query', () => {
// A gloss approved in the view beside an open catalog rebuilds every row without narrowing
// anything, and collapsing a deeply scrolled list back to its first chunk on that would throw
Expand Down
32 changes: 32 additions & 0 deletions src/__tests__/components/AnalysisStore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,22 @@ describe('useGloss', () => {
expect(screen.getByTestId('gloss')).toHaveTextContent('bonjour');
});

it('keeps an analysis written after mount across an analysisLanguage change', async () => {
const at = (analysisLanguage: string) => (
<AnalysisStoreProvider analysisLanguage={analysisLanguage}>
<GlossReader tokenRef="tok-1" />
<GlossWriter tokenRef="tok-1" surfaceText="word" value="hello" />
</AnalysisStoreProvider>
);
const { rerender } = render(at('en'));
await userEvent.click(screen.getByRole('button', { name: 'write' }));

rerender(at('fr'));
rerender(at('en'));

expect(screen.getByTestId('gloss')).toHaveTextContent('hello');
});

it('throws when called outside an AnalysisStoreProvider', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<GlossReader tokenRef="tok-1" />)).toThrow(
Expand Down Expand Up @@ -1197,6 +1213,22 @@ describe('useAnalysisLanguage', () => {
expect(screen.getByTestId('lang')).toHaveTextContent('fr');
});

it('follows a change to the analysisLanguage prop', () => {
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="fr">
<LanguageReader />
</AnalysisStoreProvider>,
);

rerender(
<AnalysisStoreProvider analysisLanguage="en">
<LanguageReader />
</AnalysisStoreProvider>,
);

expect(screen.getByTestId('lang')).toHaveTextContent('en');
});

it('throws when called outside an AnalysisStoreProvider', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<LanguageUser />)).toThrow(
Expand Down
133 changes: 133 additions & 0 deletions src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,30 @@ jest.mock('../../components/controls/ViewOptionsDropdown', () => ({
),
}));

jest.mock('../../components/controls/AnalysisLanguageSelect', () => ({
__esModule: true,
default: ({
languages,
onValueChange,
}: {
languages: readonly string[];
onValueChange: (tag: string) => void;
}) => (
<div data-testid="analysis-language-select">
{languages.map((tag) => (
<button
data-testid={`analysis-language-${tag}`}
key={tag}
onClick={() => onValueChange(tag)}
type="button"
>
{tag}
</button>
))}
</div>
),
}));

jest.mock('../../components/controls/ScriptureNavControls', () => ({
__esModule: true,
default: ({ activeBookIds }: { activeBookIds?: string[] }) => (
Expand Down Expand Up @@ -5158,3 +5182,112 @@ describe('undo and redo', () => {
expect(screen.getByRole('button', { name: '%undoButton_tooltip%' })).toBeEnabled();
});
});

describe('analysis language switch', () => {
beforeEach(() => {
capturedStoreProps = undefined;
probeStore = undefined;
mockBookData();
mockLexiconRegistry();
mockOptimisticSetting();
mockLostBoundaries([]);
mockProjectBookIds(undefined);
jest
.mocked(useData)
.mockReturnValue(
new Proxy({}, { get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]) }),
);
mockKeyAsValueLocalizedStrings();
mockSettings();
mockSourceShortName('');
});

afterEach(() => {
mountStoreProbe = false;
});

const IMPORT_LISTING_TWO = { ...STUB_IMPORT_PROJECT, analysisLanguages: ['swh', 'en'] };

/** Serves a draft listing `languages`, and {@link IMPORT_LISTING_TWO} for any import loaded. */
function mockDraftLanguages(languages: string[]): void {
mockSendCommand.mockImplementation(async (...args) =>
args[0] === 'interlinearizer.getProject'
? JSON.stringify({ ...IMPORT_LISTING_TWO, analysis: emptyAnalysis() })
: JSON.stringify({ ...emptyDraft(testProjectId), analysisLanguages: languages }),
);
}

it('reads glosses in the picked language', async () => {
mockDraftLanguages(['en', 'fr']);
await act(async () => renderLoader());

await act(async () => {
fireEvent.click(screen.getByTestId('analysis-language-fr'));
});

expect(capturedStoreProps?.analysisLanguage).toBe('fr');
});

it('switches language without replacing the store', async () => {
// A remount would discard uncommitted gloss edits and the catalog's scroll position.
mountStoreProbe = true;
mockDraftLanguages(['en', 'fr']);
await act(async () => renderLoader());
const storeBefore = probeStore;

await act(async () => {
fireEvent.click(screen.getByTestId('analysis-language-fr'));
});

expect(probeStore).toBe(storeBefore);
});

it('restores the language chosen earlier in the tab', async () => {
mockDraftLanguages(['en', 'fr']);
await act(async () =>
renderLoader({
useWebViewState: makeWebViewState({ analysisLanguageChoices: { draft: 'fr' } }),
}),
);

expect(capturedStoreProps?.analysisLanguage).toBe('fr');
});

it('falls back to the first language when the chosen one is no longer listed', async () => {
mockDraftLanguages(['en', 'fr']);
await act(async () =>
renderLoader({
useWebViewState: makeWebViewState({ analysisLanguageChoices: { draft: 'de' } }),
}),
);

expect(capturedStoreProps?.analysisLanguage).toBe('en');
});

it("keeps an import's language apart from the draft's", async () => {
mockDraftLanguages(['en', 'fr']);
await act(async () =>
renderLoader({
useWebViewState: makeWebViewState({
activeProject: IMPORT_LISTING_TWO,
analysisLanguageChoices: { draft: 'en' },
}),
}),
);

await waitFor(() => expect(capturedStoreProps?.analysisLanguage).toBe('swh'));
});

it('switches the language of a read-only import', async () => {
mockDraftLanguages(['en']);
await act(async () =>
renderLoader({ useWebViewState: makeWebViewState({ activeProject: IMPORT_LISTING_TWO }) }),
);

await act(async () => {
fireEvent.click(await screen.findByTestId('analysis-language-en'));
});

expect(capturedStoreProps?.analysisLanguage).toBe('en');
});
});
75 changes: 75 additions & 0 deletions src/__tests__/components/controls/AnalysisLanguageSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/// <reference types="jest" />
/// <reference types="@testing-library/jest-dom" />

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnalysisLanguageSelect from '../../../components/controls/AnalysisLanguageSelect';
import { mockKeyAsValueLocalizedStrings } from '../test-helpers';

beforeEach(() => {
mockKeyAsValueLocalizedStrings();
});

describe('AnalysisLanguageSelect', () => {
it('renders nothing for a single language', () => {
const { container } = render(
<AnalysisLanguageSelect languages={['en']} value="en" onValueChange={jest.fn()} />,
);

expect(container).toBeEmptyDOMElement();
});

it('labels the trigger as the analysis language', () => {
render(
<AnalysisLanguageSelect languages={['en', 'fr']} value="en" onValueChange={jest.fn()} />,
);

expect(screen.getByTestId('analysis-language-select')).toHaveAttribute(
'aria-label',
'%interlinearizer_analysisLanguage_label%',
);
});

it('names each language beside its tag, in the given locales', () => {
render(
<AnalysisLanguageSelect
languages={['swh', 'fr']}
value="swh"
onValueChange={jest.fn()}
locales={['fr']}
/>,
);

expect(screen.getByTestId('analysis-language-swh')).toHaveTextContent('swahili (swh)');
expect(screen.getByTestId('analysis-language-fr')).toHaveTextContent('français (fr)');
});

it('shows the bare tag when its name is only the tag again', () => {
// A tag no language answers to is named as itself, which would otherwise read "english (English)".
render(
<AnalysisLanguageSelect languages={['en', 'English']} value="en" onValueChange={jest.fn()} />,
);

expect(screen.getByTestId('analysis-language-English')).toHaveTextContent(/^English$/);
});

it('marks the current language selected', () => {
render(
<AnalysisLanguageSelect languages={['en', 'fr']} value="fr" onValueChange={jest.fn()} />,
);

expect(screen.getByTestId('analysis-language-fr')).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('analysis-language-en')).toHaveAttribute('aria-selected', 'false');
});

it('reports the picked language', async () => {
const onValueChange = jest.fn();
render(
<AnalysisLanguageSelect languages={['en', 'fr']} value="en" onValueChange={onValueChange} />,
);

await userEvent.click(screen.getByTestId('analysis-language-fr'));

expect(onValueChange).toHaveBeenCalledWith('fr');
});
});
27 changes: 27 additions & 0 deletions src/__tests__/store/analysisSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
writePhraseGloss,
writeSegmentFreeTranslation,
replaceAnalysis,
setAnalysisLanguage,
type AnalysisState,
} from '../../store/analysisSlice';
import { emptyAnalysis } from '../../types/empty-factories';
Expand Down Expand Up @@ -6445,3 +6446,29 @@ describe('a re-anchored analysis', () => {
expect(store.getState().analysis.analysis).toBe(before);
});
});

describe('setAnalysisLanguage', () => {
it('reads the approved gloss in the new language', () => {
const store = createAnalysisStore({
analysis: { analysis: emptyAnalysis(), analysisLanguage: 'en' },
});
store.dispatch(writeGloss('tok-1', 'word', 'hello'));

store.dispatch(setAnalysisLanguage('fr'));

expect(selectApprovedGloss(store.getState().analysis, 'tok-1')).toBe('');
});

it('writes later glosses in the new language, keeping the earlier one', () => {
const store = createAnalysisStore({
analysis: { analysis: emptyAnalysis(), analysisLanguage: 'en' },
});
store.dispatch(writeGloss('tok-1', 'word', 'hello'));

store.dispatch(setAnalysisLanguage('fr'));
store.dispatch(writeGloss('tok-1', 'word', 'bonjour'));
store.dispatch(setAnalysisLanguage('en'));

expect(selectApprovedGloss(store.getState().analysis, 'tok-1')).toBe('hello');
});
});
8 changes: 7 additions & 1 deletion src/__tests__/utils/language-tags.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
/// <reference types="jest" />

import { collatorForTag, languageNameForTag } from '../../utils/language-tags';
import { collatorForTag, languageNameForTag, parseLanguageTags } from '../../utils/language-tags';

describe('parseLanguageTags', () => {
it('keeps a repeated tag only where it first appears', () => {
expect(parseLanguageTags('en, fr, en')).toEqual(['en', 'fr']);
});
});

describe('collatorForTag', () => {
it('collates under the tag it is given', () => {
Expand Down
Loading
Loading