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 @@ -165,6 +165,7 @@
"%interlinearizer_error_pt9Import_failed%": "Could not import the Paratext 9 interlinear data. Please try again.",
"%interlinearizer_warning_pt9Import_sourceEmpty%": "The project's Paratext 9 interlinear files are missing, so the imported data was left as it was.",
"%interlinearizer_error_createEditableCopy_failed%": "Could not copy the imported project. Please try again.",
"%interlinearizer_error_pt9Import_load_failed%": "The imported interlinear data could not be loaded. Try syncing from Paratext 9.",

"%interlinearizer_modal_select_importPt9%": "Import from Paratext 9",
"%interlinearizer_readonly_chip%": "Read-only",
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/analysis-store-read-only-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Shared access to the manual `AnalysisStore` mock's read-only switch, for the several test files
* that render components under `useAnalysisReadOnly`.
*
* This lives apart from `test-helpers` on purpose: that module imports the real
* `AnalysisStoreProvider`, so in a file that mocks `AnalysisStore` its `withAnalysisStore` would
* silently render the mock's provider instead.
*/

/** The manual AnalysisStore mock's test-only controls. */
interface AnalysisStoreReadOnlyMock {
__setMockAnalysisReadOnly: (value: boolean) => void;
}

function isAnalysisStoreReadOnlyMock(m: unknown): m is AnalysisStoreReadOnlyMock {
return !!m && typeof m === 'object' && '__setMockAnalysisReadOnly' in m;
}

/**
* Sets what the mocked `useAnalysisReadOnly` returns. Resolves the mock on each call rather than at
* import time, so importing this module never depends on `jest.mock` having run first.
*
* @param value Whether the mocked store reports the analysis as read-only.
*/
export function setMockAnalysisReadOnly(value: boolean): void {
const analysisStoreMock: unknown = jest.requireMock('../components/AnalysisStore');
if (!isAnalysisStoreReadOnlyMock(analysisStoreMock))
throw new Error('Expected the AnalysisStore manual mock with read-only controls');
const { __setMockAnalysisReadOnly: setReadOnly } = analysisStoreMock;
setReadOnly(value);
}
76 changes: 75 additions & 1 deletion src/__tests__/components/ArcOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ArcOverlay } from '../../components/ArcOverlay';
import type { ArcPath } from '../../utils/phrase-arc';
import { setMockAnalysisReadOnly } from '../analysis-store-read-only-mock';
import { makePhraseLink } from '../test-helpers';
import { withTooltipProvider } from './test-helpers';

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

beforeEach(() => {
setMockAnalysisReadOnly(false);
});

/** Builds a minimal `ArcPath` fixture. */
function makeArcPath(phraseId: string, splitAfterTokenRef = 'tok-a'): ArcPath {
// `d` is derived from splitAfterTokenRef so distinct split points yield distinct
Expand Down Expand Up @@ -45,7 +52,13 @@ function requiredProps(): Parameters<typeof ArcOverlay>[0] {
* require.
*/
function renderOverlay(overrides: Partial<Parameters<typeof ArcOverlay>[0]> = {}) {
return render(withTooltipProvider(<ArcOverlay {...requiredProps()} {...overrides} />));
const props = { ...requiredProps(), ...overrides };
const result = render(withTooltipProvider(<ArcOverlay {...props} />));
return {
...result,
/** Re-renders with the same props, for a test that changed what the mocked store reports. */
rerenderOverlay: () => result.rerender(withTooltipProvider(<ArcOverlay {...props} />)),
};
}

describe('ArcOverlay', () => {
Expand Down Expand Up @@ -75,6 +88,17 @@ describe('ArcOverlay', () => {
expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument();
});

it('draws the arcs but no split buttons for a read-only analysis', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
setMockAnalysisReadOnly(true);
renderOverlay({
arcPaths: [makeArcPath('p1', 'tok-a')],
phraseLinkById: new Map([['p1', phraseLink]]),
});
expect(document.querySelectorAll('path')).toHaveLength(1);
expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument();
});

it('renders a split button in view mode even when the arc phrase is neither hovered nor focused', () => {
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
renderOverlay({
Expand Down Expand Up @@ -224,6 +248,56 @@ describe('ArcOverlay', () => {
expect(onSplitHoverChange).toHaveBeenLastCalledWith(new Set());
});

it('clears the freed-token preview when the analysis turns read-only mid-hover', async () => {
const onSplitHoverChange = jest.fn();
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b']);
const { rerenderOverlay } = renderOverlay({
arcPaths: [makeArcPath('p1', 'tok-a')],
hoveredPhraseId: 'p1',
phraseLinkById: new Map([['p1', phraseLink]]),
tokenDocOrder: new Map([
['tok-a', 0],
['tok-b', 1],
]),
onSplitHoverChange,
});
await userEvent.hover(screen.getByTestId('split-arc-btn'));
expect(onSplitHoverChange).toHaveBeenLastCalledWith(new Set(['tok-a', 'tok-b']));

// The button vanishes with the mouse still over it, so no mouse-leave of its own ever fires.
setMockAnalysisReadOnly(true);
rerenderOverlay();

expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument();
expect(onSplitHoverChange).toHaveBeenLastCalledWith(new Set());
});

it('clears the phrase highlight when the analysis turns read-only mid-reshape-hover', async () => {
const onHoverPhrase = jest.fn();
// Four-token phrase: splitting after tok-b leaves both halves ≥ 2, the reshape preview.
const phraseLink = makePhraseLink('p1', ['tok-a', 'tok-b', 'tok-c', 'tok-d']);
const { rerenderOverlay } = renderOverlay({
arcPaths: [makeArcPath('p1', 'tok-b')],
hoveredPhraseId: 'p1',
phraseLinkById: new Map([['p1', phraseLink]]),
tokenDocOrder: new Map([
['tok-a', 0],
['tok-b', 1],
['tok-c', 2],
['tok-d', 3],
]),
onHoverPhrase,
});
await userEvent.hover(screen.getByTestId('split-arc-btn'));
expect(onHoverPhrase).toHaveBeenLastCalledWith('p1');

setMockAnalysisReadOnly(true);
rerenderOverlay();

expect(screen.queryByTestId('split-arc-btn')).not.toBeInTheDocument();
expect(onHoverPhrase).toHaveBeenLastCalledWith(undefined);
});

it('does not call onSplitHoverChange with free refs on enter when no token would become free (both halves ≥ 2)', async () => {
const onSplitHoverChange = jest.fn();
// Four-token phrase: splitting after tok-b gives before=[tok-a,tok-b] and after=[tok-c,tok-d], both ≥ 2.
Expand Down
14 changes: 13 additions & 1 deletion src/__tests__/components/Interlinearizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,12 @@ const mockPhraseLinkById = new Map<string, PhraseAnalysisLink>();
/** Read once per `Interlinearizer` render, so this doubles as a render counter. */
let phraseLinkByIdMapReads = 0;

/** What the mocked `useAnalysisReadOnly` reports; reset in `beforeEach`. */
let mockReadOnly = false;

jest.mock('../../components/AnalysisStore', () => ({
__esModule: true,
useAnalysisReadOnly: () => false,
useAnalysisReadOnly: () => mockReadOnly,
/**
* Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of
* scope.
Expand Down Expand Up @@ -424,6 +427,7 @@ beforeEach(() => {
// The phrase-link map is a plain Map (not a jest mock), so resetMocks does not clear it.
mockPhraseLinkById.clear();
capturedSegmentation = undefined;
mockReadOnly = false;
// The merge control's label comes from a localized string.
mockKeyAsValueLocalizedStrings();
});
Expand Down Expand Up @@ -1641,6 +1645,14 @@ describe('between-rows merge control', () => {
expect(button).toHaveAttribute('title', 'Merge');
});

it('renders no merge control for a read-only analysis', () => {
// Omitted rather than disabled: a read-only analysis offers no boundary editing at all.
mockReadOnly = true;
renderInterlinearizer({ book: GEN_1_MULTI_BOOK });
expect(screen.queryByTestId('segment-merge-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('segment-merge-indicator')).not.toBeInTheDocument();
});

it('renders no merge control while a phrase mode is active', () => {
// A merge mid-mode could re-segment the phrase the mode UI is operating on, so the between-rows
// control is omitted entirely (not merely disabled) throughout a phrase edit.
Expand Down
Loading