Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2a36805
Reserve link-slot space when hidden to prevent arc re-alignment
imnasnainaec Jun 8, 2026
380371a
Fix punctuation jumping when link icon is hidden
imnasnainaec Jun 8, 2026
82e2b9f
Fix in-phrase punctuation duplicating on every focus change
imnasnainaec Jun 8, 2026
80b1d8c
Fix crash on punctuation after the last token of a phrase group
imnasnainaec Jun 8, 2026
47f71cd
Cut 'yet'
imnasnainaec Jun 8, 2026
4e0f451
Tune link button and arc contrast
imnasnainaec Jun 8, 2026
9895cb7
Fix punctuation layout and reduce arc contrast gap
imnasnainaec Jun 8, 2026
bd0b920
Guard mergePhrases reducer against targetPhraseId === absorbedPhraseId
imnasnainaec Jun 9, 2026
da21059
Move controls/modals tests into matching subdirectories
imnasnainaec Jun 9, 2026
86fa3ea
Disable pointer events and hide from a11y tree when link icon is supp…
imnasnainaec Jun 9, 2026
bc641d5
Import defaultScrRef from test-helpers instead of redefining it
imnasnainaec Jun 9, 2026
f749c5d
Extract makeWordToken fixture factory into test-helpers
imnasnainaec Jun 9, 2026
12fc405
Extract emptyAnalysis factory into src/types/emptyFactories.ts
imnasnainaec Jun 9, 2026
95b17c8
Reorganize types: rename to kebab-case, add @file headers, move token…
imnasnainaec Jun 9, 2026
6c77201
Unexport ArcStrokeProps — no consumers outside phrase-arc.ts
imnasnainaec Jun 9, 2026
6d84477
test: extract makeStubProject and reuse project fixtures
imnasnainaec Jun 9, 2026
324e12a
Fix idempotent punctuation routing in buildRenderUnits
imnasnainaec Jun 9, 2026
1d46fde
Update comments
imnasnainaec Jun 9, 2026
61e76a0
Make common withAnalysisStore test-helper
imnasnainaec Jun 9, 2026
e63eec4
Extract in-file project setting helpers
imnasnainaec Jun 9, 2026
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
2 changes: 1 addition & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"%interlinearizer_projectSettings_hideInactiveLinkButtonsDescription%": "Hide link buttons between phrases in segments that are not currently active",
"%interlinearizer_projectSettings_simplifyPhrases%": "Show Phrase Controls on Focus Only",
"%interlinearizer_projectSettings_simplifyPhrasesDescription%": "Hide interactive controls (split, unlink, remove-token) on phrases that are not currently focused, leaving only their style change on hover",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not yet supported. This link button is outside the current segment.",
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",

"%interlinearizer_modal_create_title%": "Create Interlinear Project",
"%interlinearizer_modal_create_name_label%": "Name (optional)",
Expand Down
93 changes: 24 additions & 69 deletions src/__tests__/components/ContinuousView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event';
import type { Book, PhraseAnalysisLink, Token } from 'interlinearizer';
import { useState, type ReactNode } from 'react';
import type { PhraseDispatch } from '../../components/AnalysisStore';
import ContinuousView from '../../components/ContinuousView';
import { AnalysisStoreProvider, type PhraseDispatch } from '../../components/AnalysisStore';
import { isWordToken } from '../../types/typeGuards';
import { isWordToken } from '../../types/type-guards';
import { withAnalysisStore } from './test-helpers';

// ---------------------------------------------------------------------------
// AnalysisStore mock — pass-through provider so AnalysisStore.tsx stays out of scope
Expand Down Expand Up @@ -44,13 +45,6 @@ jest.mock('../../components/AnalysisStore', () => ({
usePhraseGlossDispatch: () => () => {},
}));

/** Render options that wrap every test render in a `AnalysisStoreProvider`. */
const withAnalysisStore = {
wrapper({ children }: Readonly<{ children: ReactNode }>) {
return <AnalysisStoreProvider analysisLanguage="und">{children}</AnalysisStoreProvider>;
},
};

// The shared hover-preview state is covered in full by usePhraseHoverState.test.ts. Stub it here so
// ContinuousView's tests don't redundantly re-exercise the hook's internals; the view only forwards
// its handlers, which a no-op stub satisfies.
Expand Down Expand Up @@ -831,15 +825,15 @@ describe('ContinuousView scroll behavior', () => {
);
}
render(<Parent />, withAnalysisStore);
// Returns true when the tok-0/tok-1 link icon is rendered AND its sliding-door wrapper is
// open (not suppressed). After the animation change, icons stay mounted but collapse via
// maxWidth: '0' when suppressed — so we query the DOM wrapper's style rather than spy calls.
// Returns true when the tok-0/tok-1 link icon is rendered AND its wrapper is visible (not
// suppressed). Icons stay mounted but are hidden via opacity:0 when suppressed, so
// we query the DOM wrapper's style rather than spy calls.
return () => {
const icon = document.querySelector<HTMLElement>(
'[data-prev-ref="tok-0"][data-next-ref="tok-1"]',
);
if (!icon) return false;
return icon.parentElement?.style.maxWidth !== '0';
return icon.parentElement?.style.opacity !== '0';
};
}

Expand Down Expand Up @@ -983,64 +977,25 @@ describe('ContinuousView scroll behavior', () => {
);
});

it('re-centers the focused group each frame while a view-option toggle re-lays out the strip', () => {
// Toggling `hideInactiveLinkButtons` collapses/expands the out-of-segment link slots over
// LINK_SLOT_TRANSITION_MS, continuously shifting every box around the center. A single re-center
// would only fix the first frame; the focused group must be re-centered on every animation frame
// for the whole transition so it stays dead center, then the loop tears down once the transition
// completes. Fake timers drive both the rAF callbacks and performance.now() deterministically.
jest.useFakeTimers();
try {
const book = makeBook();
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
const { rerender } = render(<ContinuousView {...props} />, withAnalysisStore);
act(() => {
jest.runOnlyPendingTimers();
});
scrollIntoViewMock.mockClear();

// Toggling hideInactiveLinkButtons changes the strip layout, so the view re-centers.
rerender(<ContinuousView {...props} hideInactiveLinkButtons />);

// First animation frame re-centers.
act(() => {
jest.advanceTimersByTime(50);
});
expect(scrollIntoViewMock).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);

// Subsequent frames within the transition window re-center again.
scrollIntoViewMock.mockClear();
act(() => {
jest.advanceTimersByTime(50);
});
expect(scrollIntoViewMock).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);
it('re-centers once when simplifyPhrases toggles but not when hideInactiveLinkButtons toggles', () => {
// Inactive link slots are now hidden via visibility:hidden (not max-width collapse), so toggling
// hideInactiveLinkButtons no longer shifts the strip layout — no re-center needed.
// simplifyPhrases still affects layout, so it should trigger one re-center.
const book = makeBook();
const props = requiredProps(book, { focusedTokenRef: 'tok-0' });
const { rerender } = render(<ContinuousView {...props} />, withAnalysisStore);
scrollIntoViewMock.mockClear();

// Advance well past the transition window so the loop hits its deadline and stops scheduling.
act(() => {
jest.advanceTimersByTime(500);
});
scrollIntoViewMock.mockClear();
act(() => {
jest.advanceTimersByTime(500);
});
expect(scrollIntoViewMock).not.toHaveBeenCalled();
// Toggling hideInactiveLinkButtons should not cause any re-centering.
rerender(<ContinuousView {...props} hideInactiveLinkButtons />);
expect(scrollIntoViewMock).not.toHaveBeenCalled();

// Toggling simplifyPhrases likewise starts a fresh re-center loop.
scrollIntoViewMock.mockClear();
rerender(<ContinuousView {...props} hideInactiveLinkButtons simplifyPhrases />);
act(() => {
jest.advanceTimersByTime(50);
});
expect(scrollIntoViewMock).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);
} finally {
jest.useRealTimers();
}
// Toggling simplifyPhrases re-centers exactly once (no rAF loop needed).
rerender(<ContinuousView {...props} hideInactiveLinkButtons simplifyPhrases />);
expect(scrollIntoViewMock).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
});
});

Expand Down
22 changes: 7 additions & 15 deletions src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Dispatch, SetStateAction } from 'react';
import InterlinearizerLoader from '../../components/InterlinearizerLoader';
import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData';
import useOptimisticBooleanSetting from '../../hooks/useOptimisticBooleanSetting';
import { emptyAnalysis } from '../../types/empty-factories';
import type { PhraseMode } from '../../types/phrase-mode';
import { defaultScrRef, GEN_1_1_BOOK, makeWebViewState } from '../test-helpers';

Expand Down Expand Up @@ -109,15 +110,6 @@ const mockSendCommand = jest.mocked(papi.commands.sendCommand);

const testProjectId = 'test-project-id';

const STUB_TEXT_ANALYSIS: TextAnalysis = {
segmentAnalyses: [],
segmentAnalysisLinks: [],
tokenAnalyses: [],
tokenAnalysisLinks: [],
phraseAnalyses: [],
phraseAnalysisLinks: [],
};

const STUB_ACTIVE_PROJECT: MockProject = {
id: 'proj-1',
createdAt: '2026-01-01T00:00:00Z',
Expand Down Expand Up @@ -862,7 +854,7 @@ describe('InterlinearizerLoader', () => {
describe('project analysis loading', () => {
it('passes the stored analysis as initialAnalysis when getProject returns valid JSON', async () => {
mockSendCommand.mockResolvedValueOnce(
JSON.stringify({ id: 'proj-1', analysis: STUB_TEXT_ANALYSIS }),
JSON.stringify({ id: 'proj-1', analysis: emptyAnalysis() }),
);
await act(async () =>
render(
Expand All @@ -874,7 +866,7 @@ describe('InterlinearizerLoader', () => {
),
);

expect(capturedInterlinearizerProps?.initialAnalysis).toEqual(STUB_TEXT_ANALYSIS);
expect(capturedInterlinearizerProps?.initialAnalysis).toEqual(emptyAnalysis());
expect(mockSendCommand).toHaveBeenCalledWith('interlinearizer.getProject', 'proj-1');
});

Expand Down Expand Up @@ -915,7 +907,7 @@ describe('InterlinearizerLoader', () => {
);

unmount();
resolveGetProject?.(JSON.stringify({ id: 'proj-1', analysis: STUB_TEXT_ANALYSIS }));
resolveGetProject?.(JSON.stringify({ id: 'proj-1', analysis: emptyAnalysis() }));
await Promise.resolve();

expect(jest.mocked(logger.error)).not.toHaveBeenCalled();
Expand All @@ -934,11 +926,11 @@ describe('InterlinearizerLoader', () => {
),
);

capturedInterlinearizerProps?.onSaveAnalysis?.(STUB_TEXT_ANALYSIS);
capturedInterlinearizerProps?.onSaveAnalysis?.(emptyAnalysis());
expect(mockSendCommand).toHaveBeenCalledWith(
'interlinearizer.saveAnalysis',
'proj-1',
JSON.stringify(STUB_TEXT_ANALYSIS),
JSON.stringify(emptyAnalysis()),
);
});

Expand All @@ -951,7 +943,7 @@ describe('InterlinearizerLoader', () => {
/>,
);

capturedInterlinearizerProps?.onSaveAnalysis?.(STUB_TEXT_ANALYSIS);
capturedInterlinearizerProps?.onSaveAnalysis?.(emptyAnalysis());

expect(
mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveAnalysis'),
Expand Down
Loading