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
4 changes: 4 additions & 0 deletions __mocks__/papi-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const mockLogger = {

const mockSendCommand = jest.fn();
const mockNotificationsSend = jest.fn();
const mockProjectDataProvidersGet = jest.fn();

const papi = {
commands: {
Expand All @@ -23,6 +24,9 @@ const papi = {
menuData: {
dataProviderName: 'platform.menuDataServiceDataProvider',
},
projectDataProviders: {
get: mockProjectDataProvidersGet,
},
};

module.exports = {
Expand Down
5 changes: 5 additions & 0 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,11 @@ export function RadioGroupItem({
* {@link DialogContent} and {@link DialogTitle}, mirroring how the real Radix-based component
* reaches its parts from the root.
*/
/** Stub spinner: a marker element standing in for the platform's indeterminate spinner. */
export function Spinner({ className }: { className?: string }) {
return <span className={className} data-testid="spinner" />;
}

const DialogContext = createContext<{ onOpenChange?: (open: boolean) => void; titleId?: string }>(
{},
);
Expand Down
43 changes: 41 additions & 2 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,52 @@
"%interlinearizer_error_delete_project_failed%": "Could not delete the interlinearizer project. Please try again.",
"%interlinearizer_error_update_project_failed%": "Could not update the interlinearizer project. Please try again.",
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.",
"%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again.",
"%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again.",

"%interlinearizer_pt9Import_name%": "Paratext 9 Interlinear",
"%interlinearizer_pt9Import_description%": "Imported from this project's Paratext 9 interlinear data. Read-only; synced from the Paratext 9 files.",
"%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_save_draft_failed%": "Could not save your working draft. Please try again.",
"%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. 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",
"%interlinearizer_pt9ConvertPrompt_message%": "This project has Paratext 9 interlinear data. Would you like to convert it now?",
"%interlinearizer_pt9ConvertPrompt_yes%": "Yes",
"%interlinearizer_pt9ConvertPrompt_no%": "No",
"%interlinearizer_pt9ConvertPrompt_checking%": "Checking for Paratext 9 interlinear data…",
"%interlinearizer_pt9ImportModal_title%": "Import from Paratext 9",
"%interlinearizer_pt9ImportModal_syncTitle%": "Sync from Paratext 9",
"%interlinearizer_pt9ImportModal_importing%": "Importing from Paratext 9…",
"%interlinearizer_pt9ImportModal_syncing%": "Syncing from Paratext 9…",
"%interlinearizer_pt9ImportModal_failed%": "The Paratext 9 interlinear data could not be imported.",
"%interlinearizer_pt9ImportModal_tooLarge%": "The interlinear data in this Paratext 9 project is larger than can currently be handled.",
"%interlinearizer_pt9ImportModal_languages_label%": "Languages",
"%interlinearizer_pt9ImportModal_books_label%": "Books",
"%interlinearizer_pt9ImportModal_imported_label%": "Imported",
"%interlinearizer_pt9ImportModal_importedCounts%": "{converted} of {total} clusters",
"%interlinearizer_pt9ImportModal_phraseCounts%": "{phrases} phrases",
"%interlinearizer_pt9ImportModal_notImported_label%": "Not imported",
"%interlinearizer_pt9ImportModal_notImportedCount%": "{count} clusters: {reasons}",
"%interlinearizer_pt9ImportModal_reason_verseNotFound%": "verse not found in the project",
"%interlinearizer_pt9ImportModal_reason_formMismatch%": "did not match the project's text",
"%interlinearizer_pt9ImportModal_reason_lemmaOrOther%": "unused legacy data",
"%interlinearizer_pt9ImportModal_reason_duplicateCluster%": "duplicate data",
"%interlinearizer_pt9ImportModal_reason_unparseableLexemeId%": "unreadable data",
"%interlinearizer_pt9ImportModal_missingBooks%": "Books with no text in this project: {books}",
"%interlinearizer_pt9ImportModal_open%": "Open",
"%interlinearizer_pt9ImportModal_close%": "Close",
"%interlinearizer_banner_pt9Import%": "Imported from Paratext 9 · read-only · last synced {date}",
"%interlinearizer_banner_sync%": "Sync",
"%interlinearizer_banner_copy%": "Copy to editable",
"%interlinearizer_copyModal_title%": "Copy to editable project",
"%interlinearizer_copyModal_defaultName%": "Copy of Paratext 9 Interlinear",
"%interlinearizer_copyModal_create%": "Create copy",
"%interlinearizer_copyModal_cancel%": "Cancel",
"%interlinearizer_warning_pt9Sync_failed%": "The Paratext 9 data couldn't be refreshed; showing the last imported data.",
"%interlinearizer_modal_metadata_lastSynced_label%": "Last synced"
}
}
}
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
1 change: 1 addition & 0 deletions src/__tests__/components/ContinuousView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const mockUsePhraseDispatch = jest.fn<jest.MockedObject<PhraseDispatch>, []>().m

jest.mock('../../components/AnalysisStore', () => ({
__esModule: true,
useAnalysisReadOnly: () => false,
AnalysisStoreProvider({ children }: Readonly<{ children: ReactNode; analysisLanguage: string }>) {
return children;
},
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/components/Interlinearizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +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: () => mockReadOnly,
/**
* Pass-through provider stub that renders children directly, keeping AnalysisStore.tsx out of
* scope.
Expand Down Expand Up @@ -424,6 +428,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 +1646,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
Loading